Remove global state. Immutable mailer. (#69)

This commit is contained in:
Luca Guidi 2017-11-29 12:15:12 +01:00 committed by GitHub
parent dded5d2268
commit d2b8a32ade
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 1725 additions and 1500 deletions

View File

@ -2,3 +2,7 @@
# alphabetically
inherit_from:
- https://raw.githubusercontent.com/hanami/devtools/master/.rubocop.yml
Style/Documentation:
Exclude:
- "examples/*"
- "spec/**/*"

View File

@ -2,8 +2,9 @@ source 'https://rubygems.org'
gemspec
unless ENV['TRAVIS']
gem 'byebug', require: false, platforms: :mri
gem 'yard', require: false
gem 'byebug', require: false, platforms: :mri
gem 'allocation_stats', require: false
gem 'benchmark-ips', require: false
end
gem 'hanami-utils', '2.0.0.alpha1', require: false, git: 'https://github.com/hanami/utils.git', branch: 'unstable'

155
README.md
View File

@ -44,10 +44,10 @@ Or install it yourself as:
### Conventions
* Templates are searched under `Hanami::Mailer.configuration.root`, set this value according to your app structure (eg. `"app/templates"`).
* Templates are searched under `Hanami::Mailer::Configuration#root`, set this value according to your app structure (eg. `"app/templates"`).
* A mailer will look for a template with a file name that is composed by its full class name (eg. `"articles/index"`).
* A template must have two concatenated extensions: one for the format and one for the engine (eg. `".html.erb"`).
* The framework must be loaded before rendering the first time: `Hanami::Mailer.load!`.
* The framework must be loaded before rendering the first time: `Hanami::Mailer.finalize(configuration)`.
### Mailers
@ -55,10 +55,60 @@ A simple mailer looks like this:
```ruby
require 'hanami/mailer'
require 'ostruct'
class InvoiceMailer
include Hanami::Mailer
# Create two files: `invoice.html.erb` and `invoice.txt.erb`
configuration = Hanami::Mailer::Configuration.new do |config|
config.delivery_method = :test
end
class Invoice < Hanami::Mailer
from "noreply@example.com"
to ->(locals) { locals.fetch(:user).email }
end
configuration = Hanami::Mailer.finalize(configuration)
invoice = OpenStruct.new(number: 23)
mailer = InvoiceMailer.new(configuration: configuration)
mail = mailer.deliver(invoice: invoice)
mail
# => #<Mail::Message:70303354246540, Multipart: true, Headers: <Date: Wed, 22 Mar 2017 11:48:57 +0100>, <From: noreply@example.com>, <To: user@example.com>, <Cc: >, <Bcc: >, <Message-ID: <58d25699e47f9_b4e13ff0c503e4f4632e6@escher.mail>>, <Subject: >, <Mime-Version: 1.0>, <Content-Type: multipart/alternative; boundary=--==_mimepart_58d25699e42d2_b4e13ff0c503e4f463186>, <Content-Transfer-Encoding: 7bit>>
mail.to_s
# =>
# From: noreply@example.com
# To: user@example.com
# Message-ID: <58d25699e47f9_b4e13ff0c503e4f4632e6@escher.mail>
# Subject:
# Mime-Version: 1.0
# Content-Type: multipart/alternative;
# boundary="--==_mimepart_58d25699e42d2_b4e13ff0c503e4f463186";
# charset=UTF-8
# Content-Transfer-Encoding: 7bit
#
#
# ----==_mimepart_58d25699e42d2_b4e13ff0c503e4f463186
# Content-Type: text/plain;
# charset=UTF-8
# Content-Transfer-Encoding: 7bit
#
# Invoice #23
#
# ----==_mimepart_58d25699e42d2_b4e13ff0c503e4f463186
# Content-Type: text/html;
# charset=UTF-8
# Content-Transfer-Encoding: 7bit
#
# <html>
# <body>
# <h1>Invoice template</h1>
# </body>
# </html>
#
# ----==_mimepart_58d25699e42d2_b4e13ff0c503e4f463186--
```
A mailer with `.to` and `.from` addresses and mailer delivery:
@ -66,20 +116,18 @@ A mailer with `.to` and `.from` addresses and mailer delivery:
```ruby
require 'hanami/mailer'
Hanami::Mailer.configure do
delivery_method :smtp,
address: "smtp.gmail.com",
port: 587,
domain: "example.com",
user_name: ENV['SMTP_USERNAME'],
password: ENV['SMTP_PASSWORD'],
authentication: "plain",
enable_starttls_auto: true
end.load!
class WelcomeMailer
include Hanami::Mailer
configuration = Hanami::Mailer::Configuration.new do |config|
config.delivery_method = :smtp,
address: "smtp.gmail.com",
port: 587,
domain: "example.com",
user_name: ENV['SMTP_USERNAME'],
password: ENV['SMTP_PASSWORD'],
authentication: "plain",
enable_starttls_auto: true
end
class WelcomeMailer < Hanami::Mailer
from 'noreply@sender.com'
to 'noreply@recipient.com'
cc 'cc@sender.com'
@ -88,7 +136,7 @@ class WelcomeMailer
subject 'Welcome'
end
WelcomeMailer.deliver
WelcomeMailer.new(configuration: configuration).call(locals)
```
### Locals
@ -97,25 +145,17 @@ The set of objects passed in the `deliver` call are called `locals` and are avai
```ruby
require 'hanami/mailer'
require 'ostruct'
User = Struct.new(:name, :username, :email)
luca = User.new('Luca', 'jodosha', 'luca@jodosha.com')
class WelcomeMailer
include Hanami::Mailer
user = OpenStruct.new(name: Luca', email: 'user@hanamirb.org')
class WelcomeMailer < Hanami::Mailer
from 'noreply@sender.com'
subject 'Welcome'
to :recipient
private
def recipient
user.email
end
to ->(locals) { locals.fetch(:user).email }
end
WelcomeMailer.deliver(user: luca)
WelcomeMailer.new(configuration: configuration).deliver(user: luca)
```
The corresponding `erb` file:
@ -131,9 +171,7 @@ All public methods defined in the mailer are accessible from the template:
```ruby
require 'hanami/mailer'
class WelcomeMailer
include Hanami::Mailer
class WelcomeMailer < Hanami::Mailer
from 'noreply@sender.com'
to 'noreply@recipient.com'
subject 'Welcome'
@ -154,7 +192,7 @@ The template file must be located under the relevant `root` and must match the i
```ruby
# Given this root
Hanami::Mailer.configuration.root # => #<Pathname:app/templates>
configuration.root # => #<Pathname:app/templates>
# For InvoiceMailer, it looks for:
# * app/templates/invoice_mailer.html.erb
@ -164,9 +202,7 @@ Hanami::Mailer.configuration.root # => #<Pathname:app/templates>
If we want to specify a different template, we can do:
```ruby
class InvoiceMailer
include Hanami::Mailer
class InvoiceMailer < Hanami::Mailer
template 'invoice'
end
@ -311,21 +347,22 @@ It supports a few options:
```ruby
require 'hanami/mailer'
Hanami::Maler.configure do
configuration = Hanami::Mailer::Configuration.new do |config|
# Set the root path where to search for templates
# Argument: String, Pathname, #to_pathname, defaults to the current directory
#
root '/path/to/root'
config.root = 'path/to/root'
# Set the default charset for emails
# Argument: String, defaults to "UTF-8"
#
default_charset 'iso-8859'
config.default_charset = 'iso-8859'
# Set the delivery method
# Argument: Symbol
# Argument: Hash, optional configurations
delivery_method :stmp
config.delivery_method = :stmp
end
```
### Attachments
@ -333,14 +370,10 @@ Hanami::Maler.configure do
Attachments can be added with the following API:
```ruby
class InvoiceMailer
include Hanami::Mailer
class InvoiceMailer < Hanami::Mailer
# ...
def prepare
mail.attachments['invoice.pdf'] = '/path/to/invoice.pdf'
# or
mail.attachments['invoice.pdf'] = File.read('/path/to/invoice.pdf')
before do |mail, locals|
mail.attachments["invoice-#{locals.fetch(:invoice).number}.pdf"] = 'path/to/invoice.pdf'
end
end
```
@ -350,14 +383,14 @@ end
The global delivery method is defined through the __Hanami::Mailer__ configuration, as:
```ruby
Hanami::Mailer.configuration do
delivery_method :smtp
configuration = Hanami::Mailer::Configuration.new do |config|
config.delivery_method = :smtp
end
```
```ruby
Hanami::Mailer.configuration do
delivery_method :smtp, address: "localhost", port: 1025
configuration = Hanami::Mailer::Configuration.new do |config|
config.delivery_method = :smtp, { address: "localhost", port: 1025 }
end
```
@ -386,14 +419,14 @@ class MandrillDeliveryMethod
end
end
Hanami::Mailer.configure do
delivery_method MandrillDeliveryMethod,
username: ENV['MANDRILL_USERNAME'],
password: ENV['MANDRILL_API_KEY']
end.load!
configuration = Hanami::Mailer::Configuration.new do |config|
config.delivery_method = MandrillDeliveryMethod,
username: ENV['MANDRILL_USERNAME'],
password: ENV['MANDRILL_API_KEY']
end
```
The class passed to `.delivery_method` must accept an optional set of options
The class passed to `.delivery_method=` must accept an optional set of options
with the constructor (`#initialize`) and respond to `#deliver!`.
### Multipart Delivery
@ -402,8 +435,8 @@ All the email are sent as multipart messages by default.
For a given mailer, the framework looks up for associated text (`.txt`) and `HTML` (`.html`) templates and render them.
```ruby
InvoiceMailer.deliver # delivers both text and html templates
InvoiceMailer.deliver(format: :txt) # delivers only text template
InvoiceMailer.new(configuration: configuration).deliver({}) # delivers both text and html templates
InvoiceMailer.new(configuration: configuration).deliver(format: :txt) # delivers only text template
```
Please note that **they aren't both mandatory, but at least one of them MUST** be present.

48
benchmark.rb Normal file
View File

@ -0,0 +1,48 @@
# frozen_string_literal: true
require 'bundler/setup'
require 'hanami/mailer'
require 'benchmark/ips'
require 'allocation_stats'
require_relative './examples/base'
configuration = Hanami::Mailer::Configuration.new do |config|
config.root = "examples/base"
config.delivery_method = :test
end
configuration = Hanami::Mailer.finalize(configuration)
invoice = Invoice.new(1, 23)
user = User.new("Luca", "luca@domain.test")
mailer = InvoiceMailer.new(configuration: configuration)
Benchmark.ips do |x|
# # Configure the number of seconds used during
# # the warmup phase (default 2) and calculation phase (default 5)
# x.config(time: 5, warmup: 2)
x.report "deliver" do
mailer.deliver(invoice: invoice, user: user)
end
end
stats = AllocationStats.new(burn: 5).trace do
1_000.times do
mailer.deliver(invoice: invoice, user: user)
end
end
total_allocations = stats.allocations.all.size
puts "total allocations: #{total_allocations}"
total_memsize = stats.allocations.bytes.to_a.inject(&:+)
puts "total memsize: #{total_memsize}"
detailed_allocations = stats.allocations(alias_paths: true)
.group_by(:sourcefile, :class_plus)
.sort_by_count
.to_text
puts 'allocations by source file and class:'
puts detailed_allocations

View File

@ -1,3 +0,0 @@
---
threshold: 10
total_score: 77

View File

@ -1,2 +1,2 @@
---
threshold: 20.2
threshold: 18.4

View File

@ -25,12 +25,12 @@ DuplicateMethodCall:
FeatureEnvy:
enabled: true
exclude:
- Hanami::Mailer#build
- Hanami::Mailer#__part?
LongParameterList:
enabled: true
exclude:
- Devtools::Config#self.attribute
max_params: 2
max_params: 4
overrides: {}
LongYieldList:
enabled: true
@ -44,28 +44,23 @@ NestedIterators:
NilCheck:
enabled: true
exclude:
- Hanami::Mailer::Configuration#default_charset
- Hanami::Mailer::Configuration#delivery_method
- Hanami::Mailer::Dsl#bcc
- Hanami::Mailer::Dsl#cc
- Hanami::Mailer::Dsl#from
- Hanami::Mailer::Dsl#subject
- Hanami::Mailer::Dsl#template
- Hanami::Mailer::Dsl#templates
- Hanami::Mailer::Dsl#to
- Hanami::Mailer#__part?
RepeatedConditional:
enabled: true
max_ifs: 1
exclude:
- Hanami::Mailer::Configuration
exclude: []
TooManyConstants:
enabled: true
exclude:
- Devtools
TooManyInstanceVariables:
enabled: true
max_instance_variables: 2
max_instance_variables: 3
exclude:
- Hanami::Mailer::Configuration
TooManyMethods:
@ -76,12 +71,10 @@ TooManyStatements:
enabled: true
max_statements: 5
exclude:
- initialize
- Hanami::Mailer::Configuration#duplicate
- Hanami::Mailer::Dsl#self.extended
- Hanami::Mailer::Rendering::TemplatesFinder#find
- Hanami::Mailer#build
- Hanami::Mailer#self.included
- Hanami::Mailer#bind
- Hanami::Mailer::Configuration#initialize
- Hanami::Mailer::Dsl#self.extended
- Hanami::Mailer::TemplatesFinder#find
UncommunicativeMethodName:
enabled: true
reject:
@ -104,9 +97,7 @@ UncommunicativeParameterName:
- !ruby/regexp /[0-9]$/
- !ruby/regexp /[A-Z]/
accept: []
exclude:
- Hanami::Mailer#method_missing
- Hanami::Mailer#respond_to_missing?
exclude: []
UncommunicativeVariableName:
enabled: true
reject:
@ -114,10 +105,7 @@ UncommunicativeVariableName:
- !ruby/regexp /[0-9]$/
- !ruby/regexp /[A-Z]/
accept: []
exclude:
- Hanami::Mailer::Configuration#duplicate
- Hanami::Mailer::Configuration#load!
- Hanami::Mailer#build
exclude: []
UnusedParameters:
enabled: true
exclude: []
@ -125,15 +113,10 @@ UtilityFunction:
enabled: true
exclude:
- Devtools::Project::Initializer::Rspec#require_files # intentional for deduplication
- Hanami::Mailer::Rendering::TemplateName#tokens
max_helper_calls: 0
PrimaDonnaMethod:
exclude:
- Hanami::Mailer::Configuration
- Hanami::Mailer::Rendering::TemplateName
exclude: []
ModuleInitialize:
exclude:
- Hanami::Mailer
exclude: []
InstanceVariableAssumption:
exclude:
- Hanami::Mailer::Configuration
exclude: []

View File

@ -1,97 +0,0 @@
inherit_from:
- https://raw.githubusercontent.com/hanami/hanami/master/.rubocop.yml
Metrics/BlockLength:
Exclude:
# Ignore RSpec DSL
- spec/**/*
# Avoid parameter lists longer than four parameters.
ParameterLists:
Max: 4
CountKeywordArgs: true
# Avoid more than `Max` levels of nesting.
BlockNesting:
Max: 3
# Align with the style guide.
CollectionMethods:
PreferredMethods:
collect: 'map'
inject: 'reduce'
find: 'detect'
find_all: 'select'
# Disable documentation checking until a class needs to be documented once
Documentation:
Enabled: false
# Do not favor modifier if/unless usage when you have a single-line body
IfUnlessModifier:
Enabled: false
# Allow case equality operator (in limited use within the specs)
CaseEquality:
Enabled: false
# Constants do not always have to use SCREAMING_SNAKE_CASE
ConstantName:
Enabled: false
# Not all trivial readers/writers can be defined with attr_* methods
TrivialAccessors:
Enabled: false
# Allow empty lines around class body
EmptyLinesAroundClassBody:
Enabled: false
# Allow empty lines around module body
EmptyLinesAroundModuleBody:
Enabled: false
# Allow empty lines around block body
EmptyLinesAroundBlockBody:
Enabled: false
# Allow multiple line operations to not require indentation
MultilineOperationIndentation:
Enabled: false
# Prefer String#% over Kernel#sprintf
FormatString:
Enabled: false
# Align if/else blocks with the variable assignment
EndAlignment:
EnforcedStyleAlignWith: variable
# Do not always align parameters when it is easier to read
AlignParameters:
Exclude:
- spec/**/*_spec.rb
# Prefer #kind_of? over #is_a?
ClassCheck:
EnforcedStyle: kind_of?
# Do not prefer double quotes to be used when %q or %Q is more appropriate
UnneededPercentQ:
Enabled: false
# Do not prefer lambda.call(...) over lambda.(...)
LambdaCall:
Enabled: false
# Allow additional spaces
ExtraSpacing:
Enabled: false
# All objects can still be mutated if their eigenclass is patched
RedundantFreeze:
Enabled: false
# Prefer using `fail` when raising and `raise` when reraising
SignalException:
Enabled: false

46
examples/base.rb Normal file
View File

@ -0,0 +1,46 @@
# frozen_string_literal: true
require 'bundler/setup'
require 'hanami/mailer'
configuration = Hanami::Mailer::Configuration.new do |config|
config.root = File.expand_path(__dir__, "base")
config.delivery_method = :test
end
class Invoice
attr_reader :id, :number
def initialize(id, number)
@id = id
@number = number
freeze
end
end
class User
attr_reader :name, :email
def initialize(name, email)
@name = name
@email = email
freeze
end
end
class InvoiceMailer < Hanami::Mailer
template "invoice"
from "invoices@domain.test"
to ->(locals) { locals.fetch(:user).email }
subject ->(locals) { "Invoice ##{locals.fetch(:invoice).number}" }
end
configuration = Hanami::Mailer.finalize(configuration)
invoice = Invoice.new(1, 23)
user = User.new("Luca", "luca@domain.test")
mailer = InvoiceMailer.new(configuration: configuration)
puts mailer.deliver(invoice: invoice, user: user)

View File

@ -0,0 +1,8 @@
<html>
<head>
<title>Invoice</title>
</head>
<body>
<h1>Invoice #<%= invoice.number %></h1>
</body>
</html>

View File

@ -0,0 +1 @@
Invoice #<%= invoice.number %>

View File

@ -25,5 +25,5 @@ Gem::Specification.new do |spec|
spec.add_development_dependency 'bundler', '~> 1.15'
spec.add_development_dependency 'rake', '~> 12'
spec.add_development_dependency 'rspec', '~> 3.5'
spec.add_development_dependency 'rspec', '~> 3.7'
end

View File

@ -1 +0,0 @@
require 'hanami/mailer' # rubocop:disable Naming/FileName

View File

@ -1,8 +1,7 @@
require 'hanami/utils/class_attribute'
require 'hanami/mailer/version'
require 'hanami/mailer/configuration'
require 'hanami/mailer/dsl'
# frozen_string_literal: true
require 'mail'
require 'concurrent'
# Hanami
#
@ -11,23 +10,12 @@ module Hanami
# Hanami::Mailer
#
# @since 0.1.0
module Mailer
# Base error for Hanami::Mailer
#
# @since 0.1.0
class Error < ::StandardError
end
# Missing delivery data error
#
# It's raised when a mailer doesn't specify <tt>from</tt> or <tt>to</tt>.
#
# @since 0.1.0
class MissingDeliveryDataError < Error
def initialize
super("Missing delivery data, please check 'from', or 'to'")
end
end
class Mailer
require 'hanami/mailer/version'
require 'hanami/mailer/template'
require 'hanami/mailer/finalizer'
require 'hanami/mailer/configuration'
require 'hanami/mailer/dsl'
# Content types mapping
#
@ -38,173 +26,156 @@ module Hanami
txt: 'text/plain'
}.freeze
include Utils::ClassAttribute
private_constant(:CONTENT_TYPES)
# @since 0.1.0
# @api private
class_attribute :configuration
self.configuration = Configuration.new
# Configure the framework.
# It yields the given block in the context of the configuration
#
# @param blk [Proc] the configuration block
# Base error for Hanami::Mailer
#
# @since 0.1.0
#
# @see Hanami::Mailer::Configuration
#
# @example
# require 'hanami/mailer'
#
# Hanami::Mailer.configure do
# root '/path/to/root'
# end
def self.configure(&blk)
configuration.instance_eval(&blk)
self
class Error < ::StandardError
end
# Unknown mailer
#
# This error is raised at the runtime when trying to deliver a mail message,
# by using a configuration that it wasn't finalized yet.
#
# @since next
# @api unstable
#
# @see Hanami::Mailer.finalize
class UnknownMailerError < Error
# @param mailer [Hanami::Mailer] a mailer
#
# @since next
# @api unstable
def initialize(mailer)
super("Unknown mailer: #{mailer.inspect}. Please finalize the configuration before to use it.")
end
end
# Missing delivery data error
#
# It's raised when a mailer doesn't specify `from` or `to`.
#
# @since 0.1.0
class MissingDeliveryDataError < Error
def initialize
super("Missing delivery data, please check 'from', or 'to'")
end
end
# @since next
# @api unstable
@_subclasses = Concurrent::Array.new
# Override Ruby's hook for modules.
# It includes basic Hanami::Mailer modules to the given Class.
# It includes basic `Hanami::Mailer` modules to the given Class.
# It sets a copy of the framework configuration
#
# @param base [Class] the target mailer
#
# @since 0.1.0
# @api private
#
# @see http://www.ruby-doc.org/core/Module.html#method-i-included
def self.included(base)
conf = configuration
conf.add_mailer(base)
base.class_eval do
extend Dsl
extend ClassMethods
include Utils::ClassAttribute
class_attribute :configuration
self.configuration = conf.duplicate
end
conf.copy!(base)
# @since next
# @api unstable
def self.inherited(base)
@_subclasses.push(base)
base.extend Dsl
end
# Test deliveries
private_class_method :inherited
# Finalize the configuration
#
# This is a collection of delivered messages, used when <tt>delivery_method</tt>
# is set on <tt>:test</tt>
# This should be used before to start to use the mailers
#
# @return [Array] a collection of delivered messages
# @param configuration [Hanami::Mailer::Configuration] the configuration to
# finalize
#
# @since 0.1.0
# @return [Hanami::Mailer::Configuration] the finalized configuration
#
# @see Hanami::Mailer::Configuration#delivery_mode
# @since next
# @api unstable
#
# @example
# require 'hanami/mailer'
#
# Hanami::Mailer.configure do
# delivery_method :test
# end.load!
# configuration = Hanami::Mailer::Configuration.new do |config|
# # ...
# end
#
# # In testing code
# Signup::Welcome.deliver
# Hanami::Mailer.deliveries.count # => 1
def self.deliveries
Mail::TestMailer.deliveries
end
# Load the framework
#
# @since 0.1.0
# @api private
def self.load!
Mail.eager_autoload!
configuration.load!
end
# @since 0.1.0
module ClassMethods
# Delivers a multipart email message.
#
# When a mailer defines a <tt>html</tt> and <tt>txt</tt> template, they are
# both delivered.
#
# In order to selectively deliver only one of the two templates, use
# <tt>Signup::Welcome.deliver(format: :txt)</tt>
#
# All the given locals, excepted the reserved ones (<tt>:format</tt> and
# <tt>charset</tt>), are available as rendering context for the templates.
#
# @param locals [Hash] a set of objects that acts as context for the rendering
# @option :format [Symbol] specify format to deliver
# @option :charset [String] charset
#
# @since 0.1.0
#
# @see Hanami::Mailer::Configuration#default_charset
#
# @example
# require 'hanami/mailer'
#
# Hanami::Mailer.configure do
# delivery_method :smtp
# end.load!
#
# module Billing
# class Invoice
# include Hanami::Mailer
#
# from 'noreply@example.com'
# to :recipient
# subject :subject_line
#
# def prepare
# mail.attachments['invoice.pdf'] = File.read('/path/to/invoice.pdf')
# end
#
# private
#
# def recipient
# user.email
# end
#
# def subject_line
# "Invoice - #{ invoice.number }"
# end
# end
# end
#
# invoice = Invoice.new
# user = User.new(name: 'L', email: 'user@example.com')
#
# Billing::Invoice.deliver(invoice: invoice, user: user) # Deliver both text, HTML parts and the attachment
# Billing::Invoice.deliver(invoice: invoice, user: user, format: :txt) # Deliver only the text part and the attachment
# Billing::Invoice.deliver(invoice: invoice, user: user, format: :html) # Deliver only the text part and the attachment
# Billing::Invoice.deliver(invoice: invoice, user: user, charset: 'iso-8859') # Deliver both the parts with "iso-8859"
def deliver(locals = {})
new(locals).deliver
end
# configuration = Hanami::Mailer.finalize(configuration)
# MyMailer.new(configuration: configuration)
def self.finalize(configuration)
Finalizer.finalize(@_subclasses, configuration)
end
# Initialize a mailer
#
# @param locals [Hash] a set of objects that acts as context for the rendering
# @option :format [Symbol] specify format to deliver
# @option :charset [String] charset
# @param configuration [Hanami::Mailer::Configuration] the configuration
# @return [Hanami::Mailer]
#
# @since 0.1.0
def initialize(locals = {})
@locals = locals
@format = locals.fetch(:format, nil)
@charset = locals.fetch(:charset, self.class.configuration.default_charset)
@mail = build
prepare
def initialize(configuration:)
@configuration = configuration
freeze
end
# Prepare the email message when a new mailer is initialized.
#
# @return [Mail::Message] the delivered email
#
# @since 0.1.0
# @api unstable
#
# @see Hanami::Mailer::Configuration#default_charset
#
# @example
# require 'hanami/mailer'
#
# configuration = Hanami::Mailer::Configuration.new do |config|
# config.delivery_method = :smtp
# end
#
# configuration = Hanami::Mailer.finalize(configuration)
#
# module Billing
# class InvoiceMailer < Hanami::Mailer
# from 'noreply@example.com'
# to ->(locals) { locals.fetch(:user).email }
# subject ->(locals) { "Invoice number #{locals.fetch(:invoice).number}" }
#
# before do |mail, locals|
# mail.attachments["invoice-#{locals.fetch(:invoice).number}.pdf"] =
# File.read('/path/to/invoice.pdf')
# end
# end
# end
#
# invoice = Invoice.new(number: 23)
# user = User.new(name: 'L', email: 'user@example.com')
#
# mailer = Billing::InvoiceMailer.new(configuration: configuration)
#
# # Deliver both text, HTML parts and the attachment
# mailer.deliver(invoice: invoice, user: user)
#
# # Deliver only the text part and the attachment
# mailer.deliver(invoice: invoice, user: user, format: :txt)
#
# # Deliver only the text part and the attachment
# mailer.deliver(invoice: invoice, user: user, format: :html)
#
# # Deliver both the parts with "iso-8859"
# mailer.deliver(invoice: invoice, user: user, charset: 'iso-8859')
def deliver(locals)
mail(locals).deliver
rescue ArgumentError
raise MissingDeliveryDataError
end
# @since next
# @api unstable
alias call deliver
# Render a single template with the specified format.
#
# @param format [Symbol] format
@ -212,127 +183,78 @@ module Hanami
# @return [String] the output of the rendering process.
#
# @since 0.1.0
# @api private
def render(format)
self.class.templates(format).render(self, @locals)
# @api unstable
def render(format, locals)
template(format).render(self, locals)
end
# Delivers a multipart email, by looking at all the associated templates and render them.
#
# @since 0.1.0
# @api private
def deliver
mail.deliver
rescue ArgumentError => exception
raise MissingDeliveryDataError if exception.message =~ /SMTP (From|To) address/
raise
end
protected
# Prepare the email message when a new mailer is initialized.
#
# This is a hook that can be overwritten by mailers.
#
# @since 0.1.0
#
# @example
# require 'hanami/mailer'
#
# module Billing
# class Invoice
# include Hanami::Mailer
#
# subject 'Invoice'
# from 'noreply@example.com'
# to ''
#
# def prepare
# mail.attachments['invoice.pdf'] = File.read('/path/to/invoice.pdf')
# end
#
# private
#
# def recipient
# user.email
# end
# end
# end
#
# invoice = Invoice.new
# user = User.new(name: 'L', email: 'user@example.com')
def prepare
end
# @api private
# @since 0.1.0
def method_missing(m)
@locals.fetch(m) { super }
end
# @since 0.1.0
attr_reader :mail
# @api private
# @since 0.1.0
attr_reader :charset
private
# rubocop:disable Metrics/MethodLength
# rubocop:disable Metrics/AbcSize
# @api private
def build
Mail.new.tap do |m|
m.from = __dsl(:from)
m.to = __dsl(:to)
m.cc = __dsl(:cc)
m.bcc = __dsl(:bcc)
m.subject = __dsl(:subject)
# @api unstable
# @since next
attr_reader :configuration
m.charset = charset
m.html_part = __part(:html)
m.text_part = __part(:txt)
m.delivery_method(*Hanami::Mailer.configuration.delivery_method)
# @api unstable
# @since next
def mail(locals)
Mail.new.tap do |mail|
instance_exec(mail, locals, &self.class.before)
bind(mail, locals)
end
end
# rubocop:enable Metrics/MethodLength
# rubocop:enable Metrics/AbcSize
# @api private
# @api unstable
# @since next
def bind(mail, locals) # rubocop:disable Metrics/AbcSize
charset = locals.fetch(:charset, configuration.default_charset)
mail.from = __dsl(:from, locals)
mail.to = __dsl(:to, locals)
mail.cc = __dsl(:cc, locals)
mail.bcc = __dsl(:bcc, locals)
mail.subject = __dsl(:subject, locals)
mail.html_part = __part(:html, charset, locals)
mail.text_part = __part(:txt, charset, locals)
mail.charset = charset
mail.delivery_method(*configuration.delivery_method)
end
# @since next
# @api unstable
def template(format)
configuration.template(self.class, format)
end
# @since 0.1.0
def __dsl(method_name)
# @api unstable
def __dsl(method_name, locals)
case result = self.class.__send__(method_name)
when Symbol
__send__(result)
when Proc
result.call(locals)
else
result
end
end
# @api private
# @since 0.1.0
def __part(format)
return unless __part?(format)
# @api unstable
def __part(format, charset, locals)
return unless __part?(format, locals)
Mail::Part.new.tap do |part|
part.content_type = "#{CONTENT_TYPES.fetch(format)}; charset=#{charset}"
part.body = render(format)
part.body = render(format, locals)
end
end
# @api private
# @since 0.1.0
def __part?(format)
@format == format ||
(!@format && !self.class.templates(format).nil?)
end
# @api private
# @since 0.4.0
def respond_to_missing?(m, _include_all)
@locals.key?(m)
# @api unstable
def __part?(format, locals)
wanted = locals.fetch(:format, nil)
wanted == format ||
(!wanted && !template(format).nil?)
end
end
end

View File

@ -1,8 +1,11 @@
require 'set'
# frozen_string_literal: true
require 'hanami/utils/kernel'
require 'hanami/mailer/template_name'
require 'hanami/mailer/templates_finder'
module Hanami
module Mailer
class Mailer
# Framework configuration
#
# @since 0.1.0
@ -11,7 +14,7 @@ module Hanami
#
# @since 0.1.0
# @api private
DEFAULT_ROOT = '.'.freeze
DEFAULT_ROOT = '.'
# Default delivery method
#
@ -23,24 +26,33 @@ module Hanami
#
# @since 0.1.0
# @api private
DEFAULT_CHARSET = 'UTF-8'.freeze
DEFAULT_CHARSET = 'UTF-8'
# @since 0.1.0
# @api private
attr_reader :mailers
# @since 0.1.0
# @api private
attr_reader :modules
private_constant(*constants(false))
# Initialize a configuration instance
#
# @yield [config] the new initialized configuration instance
# @return [Hanami::Mailer::Configuration] a new configuration's instance
#
# @since 0.1.0
#
# @example Basic Usage
# require 'hanami/mailer'
#
# configuration = Hanami::Mailer::Configuration.new do |config|
# config.delivery_method :smtp, ...
# end
def initialize
@namespace = Object
reset!
@mailers = {}
self.namespace = Object
self.root = DEFAULT_ROOT
self.delivery_method = DEFAULT_DELIVERY_METHOD
self.default_charset = DEFAULT_CHARSET
yield(self) if block_given?
@finder = TemplatesFinder.new(root)
end
# Set the Ruby namespace where to lookup for mailers.
@ -49,91 +61,52 @@ module Hanami
# that if a `MyApp` wants a `Mailers::Signup` mailer, we are loading the
# right one.
#
# If not set, this value defaults to `Object`.
# @!attribute namespace
# @return [Class,Module,String] the Ruby namespace where the mailers
# are located
#
# This is part of a DSL, for this reason when this method is called with
# an argument, it will set the corresponding instance variable. When
# called without, it will return the already set value, or the default.
# @since next
# @api unstable
#
# @overload namespace(value)
# Sets the given value
# @param value [Class, Module, String] a valid Ruby namespace identifier
#
# @overload namespace
# Gets the value
# @return [Class, Module, String]
#
# @api private
# @since 0.1.0
#
# @example Getting the value
# @example
# require 'hanami/mailer'
#
# Hanami::Mailer.configuration.namespace # => Object
#
# @example Setting the value
# require 'hanami/mailer'
#
# Hanami::Mailer.configure do
# namespace 'MyApp::Mailers'
# Hanami::Mailer::Configuration.new do |config|
# config.namespace = MyApp::Mailers
# end
def namespace(value = nil)
if value
@namespace = value
else
@namespace
end
end
attr_accessor :namespace
# Set the root path where to search for templates
#
# If not set, this value defaults to the current directory.
#
# When this method is called with an argument, it will set the corresponding instance variable.
# When called without, it will return the already set value, or the default.
# @param value [String, Pathname] the root path for mailer templates
#
# @overload root(value)
# Sets the given value
# @param value [String, Pathname, #to_pathname] an object that can be
# coerced to Pathname
# @raise [Errno::ENOENT] if the path doesn't exist
#
# @overload root
# Gets the value
# @return [Pathname]
#
# @since 0.1.0
# @since next
# @api unstable
#
# @see http://www.ruby-doc.org/stdlib/libdoc/pathname/rdoc/Pathname.html
# @see http://rdoc.info/gems/hanami-utils/Hanami/Utils/Kernel#Pathname-class_method
#
# @example Getting the value
# @example
# require 'hanami/mailer'
#
# Hanami::Mailer.configuration.root # => #<Pathname:.>
#
# @example Setting the value
# require 'hanami/mailer'
#
# Hanami::Mailer.configure do
# root '/path/to/templates'
# Hanami::Mailer::Configuration.new do |config|
# config.root = 'path/to/templates'
# end
#
# Hanami::Mailer.configuration.root # => #<Pathname:/path/to/templates>
def root(value = nil)
if value
@root = Utils::Kernel.Pathname(value).realpath
else
@root
end
def root=(value)
@root = Utils::Kernel.Pathname(value).realpath
end
# Prepare the mailers.
#
# The given block will be yielded when `Hanami::Mailer` will be included by
# a mailer.
#
# This method can be called multiple times.
# @!attribute [r] root
# @return [Pathname] the root path for mailer templates
#
# @since next
# @api unstable
attr_reader :root
# @param blk [Proc] the code block
#
# @return [void]
@ -144,19 +117,8 @@ module Hanami
#
# @see Hanami::Mailer.configure
def prepare(&blk)
if block_given? # rubocop:disable Style/GuardClause
@modules.push(blk)
else
raise ArgumentError.new('Please provide a block')
end
end
# Add a mailer to the registry
#
# @since 0.1.0
# @api private
def add_mailer(mailer)
@mailers.add(mailer)
raise ArgumentError.new('Please provide a block') unless block_given?
@modules.push(blk)
end
# Duplicate by copying the settings in a new instance.
@ -211,40 +173,41 @@ module Hanami
#
# It supports the following delivery methods:
#
# * Exim (<tt>:exim</tt>)
# * Sendmail (<tt>:sendmail</tt>)
# * SMTP (<tt>:smtp</tt>, for local installations)
# * SMTP Connection (<tt>:smtp_connection</tt>,
# via <tt>Net::SMTP</tt> - for remote installations)
# * Test (<tt>:test</tt>, for testing purposes)
# * Exim (`:exim`)
# * Sendmail (`:sendmail`)
# * SMTP (`:smtp`, for local installations)
# * SMTP Connection (`:smtp_connection`,
# via `Net::SMTP` - for remote installations)
# * Test (`:test`, for testing purposes)
#
# The default delivery method is SMTP (<tt>:smtp</tt>).
# The default delivery method is SMTP (`:smtp`).
#
# Custom delivery methods can be specified by passing the class policy and
# a set of optional configurations. This class MUST respond to:
#
# * <tt>initialize(options = {})</tt>
# * <tt>deliver!(mail)<tt>
# * `initialize(options = {})`
# * `deliver!(mail)`
#
# @param method [Symbol, #initialize, deliver!] delivery method
# @param options [Hash] optional settings
#
# @return [Array] an array containing the delivery method and the optional settings as an Hash
#
# @since 0.1.0
# @since next
# @api unstable
#
# @example Setup delivery method with supported symbol
# require 'hanami/mailer'
#
# Hanami::Mailer.configure do
# delivery_method :sendmail
# Hanami::Mailer::Configuration.new do |config|
# config.delivery_method = :sendmail
# end
#
# @example Setup delivery method with supported symbol and options
# require 'hanami/mailer'
#
# Hanami::Mailer.configure do
# delivery_method :smtp, address: "localhost", port: 1025
# Hanami::Mailer::Configuration.new do |config|
# config.delivery_method = :smtp, address: "localhost", port: 1025
# end
#
# @example Setup custom delivery method with options
@ -260,49 +223,77 @@ module Hanami
# end
# end
#
# Hanami::Mailer.configure do
# delivery_method MandrillDeliveryMethod,
# username: ENV['MANDRILL_USERNAME'],
# password: ENV['MANDRILL_API_KEY']
# Hanami::Mailer.Configuration.new do |config|
# config.delivery_method = MandrillDeliveryMethod,
# username: ENV['MANDRILL_USERNAME'],
# password: ENV['MANDRILL_API_KEY']
# end
def delivery_method(method = nil, options = {})
if method.nil?
@delivery_method
else
@delivery_method = [method, options]
end
attr_accessor :delivery_method
# Specify a default charset for all the delivered emails
#
# If not set, it defaults to `UTF-8`
#
# @!attribute default_charset
# @return [String] the charset
#
# @since next
# @api unstable
#
# @example
# require 'hanami/mailer'
#
# Hanami::Mailer::Configuration.new do |config|
# config.default_charset = "iso-8859-1"
# end
attr_accessor :default_charset
# Add a mailer to the registry
#
# @param mailer [Hanami::Mailer] a mailer
#
# @since 0.1.0
# @api unstable
def add_mailer(mailer)
template_name = TemplateName[mailer.template_name, namespace]
templates = finder.find(template_name)
mailers[mailer] = templates
end
# @since 0.1.0
def default_charset(value = nil)
if value.nil?
@default_charset
else
@default_charset = value
end
# @param mailer [Hanami::Mailer] a mailer
# @param format [Symbol] the wanted format (eg. `:html`, `:txt`)
#
# @raise [Hanami::Mailer::UnknownMailerError] if the given mailer is not
# present in the configuration. This happens when the configuration is
# used before to being finalized.
#
# @since next
# @api unstable
def template(mailer, format)
mailers.fetch(mailer) { raise UnknownMailerError.new(mailer) }[format]
end
protected
# Deep freeze the important instance variables
#
# @since next
# @api unstable
def freeze
delivery_method.freeze
default_charset.freeze
mailers.freeze
super
end
# @api private
# @since 0.1.0
attr_writer :root
private
# @api private
# @since 0.1.0
attr_writer :delivery_method
# @api private
attr_reader :mailers
# @api private
# @since 0.1.0
attr_writer :default_charset
# @api private
# @since 0.1.0
attr_writer :namespace
# @api private
# @since 0.1.0
attr_writer :modules
# @since next
# @api unstable
attr_reader :finder
end
end
end

View File

@ -1,89 +1,31 @@
require 'hanami/mailer/rendering/template_name'
require 'hanami/mailer/rendering/templates_finder'
# frozen_string_literal: true
module Hanami
module Mailer
# Hanami::Mailer
#
# @since 0.1.0
class Mailer
require 'hanami/mailer/template_name'
# Class level DSL
#
# @since 0.1.0
module Dsl
# @since 0.3.0
# @api private
# @api unstable
def self.extended(base)
base.class_eval do
@from = nil
@to = nil
@cc = nil
@bcc = nil
@subject = nil
@from = nil
@to = nil
@cc = nil
@bcc = nil
@subject = nil
@template = nil
@before = ->(*) {}
end
end
# Set the template name IF it differs from the convention.
#
# For a given mailer named <tt>Signup::Welcome</tt> it will look for
# <tt>signup/welcome.*.*</tt> templates under the root directory.
#
# If for some reason, we need to specify a different template name, we can
# use this method.
#
# This is part of a DSL, for this reason when this method is called with
# an argument, it will set the corresponding class variable. When
# called without, it will return the already set value, or the default.
#
# @overload template(value)
# Sets the given value
# @param value [String, #to_s] relative template path, under root
# @return [NilClass]
#
# @overload template
# Gets the template name
# @return [String]
#
# @since 0.1.0
#
# @see Hanami::Mailers::Configuration.root
#
# @example Custom template name
# require 'hanami/mailer'
#
# class MyMailer
# include Hanami::Mailer
# template 'mailer'
# end
def template(value = nil)
if value.nil?
@template ||= ::Hanami::Mailer::Rendering::TemplateName.new(name, configuration.namespace).to_s
else
@template = value
end
end
# Returns a set of associated templates or only one for the given format
#
# This is part of a DSL, for this reason when this method is called with
# an argument, it will set the corresponding class variable. When
# called without, it will return the already set value, or the default.
#
# @overload templates(format)
# Returns the template associated with the given format
# @param value [Symbol] the format
# @return [Hash]
#
# @overload templates
# Returns all the associated templates
# Gets the template name
# @return [Hash] a set of templates
#
# @since 0.1.0
# @api private
def templates(format = nil)
if format.nil?
@templates = ::Hanami::Mailer::Rendering::TemplatesFinder.new(self).find
else
@templates.fetch(format, nil)
end
end
private_class_method :extended
# Sets the sender for mail messages
#
@ -114,24 +56,15 @@ module Hanami
# @example Hardcoded value (String)
# require 'hanami/mailer'
#
# class WelcomeMailer
# include Hanami::Mailer
#
# class WelcomeMailer < Hanami::Mailer
# from "noreply@example.com"
# end
#
# @example Method (Symbol)
# @example Lazy (Proc)
# require 'hanami/mailer'
#
# class WelcomeMailer
# include Hanami::Mailer
# from :sender
#
# private
#
# def sender
# "noreply@example.com"
# end
# class WelcomeMailer < Hanami::Mailer
# from ->(locals) { locals.fetch(:sender).email }
# end
def from(value = nil)
if value.nil?
@ -141,6 +74,74 @@ module Hanami
end
end
# Sets the recipient for mail messages
#
# It accepts a hardcoded value as a string or array of strings.
# For dynamic values, you can specify a symbol that represents an instance
# method.
#
# This value MUST be set, otherwise an exception is raised at the delivery
# time.
#
# When a value is given, specify the recipient of the email
# Otherwise, it returns the recipient of the email
#
# This is part of a DSL, for this reason when this method is called with
# an argument, it will set the corresponding class variable. When
# called without, it will return the already set value, or the default.
#
# @overload to(value)
# Sets the recipient
# @param value [String, Array, Symbol] the hardcoded value or method name
# @return [NilClass]
#
# @overload to
# Returns the recipient
# @return [String, Array, Symbol] the recipient
#
# @since 0.1.0
#
# @example Hardcoded value (String)
# require 'hanami/mailer'
#
# class WelcomeMailer < Hanami::Mailer
# to "user@example.com"
# end
#
# @example Hardcoded value (Array)
# require 'hanami/mailer'
#
# class WelcomeMailer < Hanami::Mailer
# to ["user-1@example.com", "user-2@example.com"]
# end
#
# @example Lazy value (Proc)
# require 'hanami/mailer'
#
# class WelcomeMailer < Hanami::Mailer
# to ->(locals) { locals.fetch(:user).email }
# end
#
# user = User.new(name: 'L')
# WelcomeMailer.new(configuration: configuration).deliver(user: user)
#
# @example Lazy values (Proc)
# require 'hanami/mailer'
#
# class WelcomeMailer < Hanami::Mailer
# to ->(locals) { locals.fetch(:users).map(&:email) }
# end
#
# users = [User.new(name: 'L'), User.new(name: 'MG')]
# WelcomeMailer.new(configuration: configuration).deliver(users: users)
def to(value = nil)
if value.nil?
@to
else
@to = value
end
end
# Sets the cc (carbon copy) for mail messages
#
# It accepts a hardcoded value as a string or array of strings.
@ -170,58 +171,36 @@ module Hanami
# @example Hardcoded value (String)
# require 'hanami/mailer'
#
# class WelcomeMailer
# include Hanami::Mailer
#
# to "user@example.com"
# class WelcomeMailer < Hanami::Mailer
# cc "other.user@example.com"
# end
#
# @example Hardcoded value (Array)
# require 'hanami/mailer'
#
# class WelcomeMailer
# include Hanami::Mailer
#
# to ["user-1@example.com", "user-2@example.com"]
# class WelcomeMailer < Hanami::Mailer
# cc ["other.user-1@example.com", "other.user-2@example.com"]
# end
#
# @example Method (Symbol)
# @example Lazy value (Proc)
# require 'hanami/mailer'
#
# class WelcomeMailer
# include Hanami::Mailer
# to "user@example.com"
# cc :email_address
#
# private
#
# def email_address
# user.email
# end
# class WelcomeMailer < Hanami::Mailer
# cc ->(locals) { locals.fetch(:user).email }
# end
#
# other_user = User.new(name: 'L')
# WelcomeMailer.deliver(user: other_user)
# user = User.new(name: 'L')
# WelcomeMailer.new(configuration: configuration).deliver(user: user)
#
# @example Method that returns a collection of recipients
# @example Lazy values (Proc)
# require 'hanami/mailer'
#
# class WelcomeMailer
# include Hanami::Mailer
# to "user@example.com"
# cc :recipients
#
# private
#
# def recipients
# users.map(&:email)
# end
# class WelcomeMailer < Hanami::Mailer
# cc ->(locals) { locals.fetch(:users).map(&:email) }
# end
#
# other_users = [User.new(name: 'L'), User.new(name: 'MG')]
# WelcomeMailer.deliver(users: other_users)
# users = [User.new(name: 'L'), User.new(name: 'MG')]
# WelcomeMailer.new(configuration: configuration).deliver(users: users)
def cc(value = nil)
if value.nil?
@cc
@ -259,58 +238,36 @@ module Hanami
# @example Hardcoded value (String)
# require 'hanami/mailer'
#
# class WelcomeMailer
# include Hanami::Mailer
#
# to "user@example.com"
# class WelcomeMailer < Hanami::Mailer
# bcc "other.user@example.com"
# end
#
# @example Hardcoded value (Array)
# require 'hanami/mailer'
#
# class WelcomeMailer
# include Hanami::Mailer
#
# to ["user-1@example.com", "user-2@example.com"]
# class WelcomeMailer < Hanami::Mailer
# bcc ["other.user-1@example.com", "other.user-2@example.com"]
# end
#
# @example Method (Symbol)
# @example Lazy value (Proc)
# require 'hanami/mailer'
#
# class WelcomeMailer
# include Hanami::Mailer
# to "user@example.com"
# bcc :email_address
#
# private
#
# def email_address
# user.email
# end
# class WelcomeMailer < Hanami::Mailer
# bcc ->(locals) { locals.fetch(:user).email }
# end
#
# other_user = User.new(name: 'L')
# WelcomeMailer.deliver(user: other_user)
# user = User.new(name: 'L')
# WelcomeMailer.new(configuration: configuration).deliver(user: user)
#
# @example Method that returns a collection of recipients
# @example Lazy values (Proc)
# require 'hanami/mailer'
#
# class WelcomeMailer
# include Hanami::Mailer
# to "user@example.com"
# bcc :recipients
#
# private
#
# def recipients
# users.map(&:email)
# end
# class WelcomeMailer < Hanami::Mailer
# bcc ->(locals) { locals.fetch(:users).map(&:email) }
# end
#
# other_users = [User.new(name: 'L'), User.new(name: 'MG')]
# WelcomeMailer.deliver(users: other_users)
# users = [User.new(name: 'L'), User.new(name: 'MG')]
# WelcomeMailer.new(configuration: configuration).deliver(users: users)
def bcc(value = nil)
if value.nil?
@bcc
@ -319,92 +276,6 @@ module Hanami
end
end
# Sets the recipient for mail messages
#
# It accepts a hardcoded value as a string or array of strings.
# For dynamic values, you can specify a symbol that represents an instance
# method.
#
# This value MUST be set, otherwise an exception is raised at the delivery
# time.
#
# When a value is given, specify the recipient of the email
# Otherwise, it returns the recipient of the email
#
# This is part of a DSL, for this reason when this method is called with
# an argument, it will set the corresponding class variable. When
# called without, it will return the already set value, or the default.
#
# @overload to(value)
# Sets the recipient
# @param value [String, Array, Symbol] the hardcoded value or method name
# @return [NilClass]
#
# @overload to
# Returns the recipient
# @return [String, Array, Symbol] the recipient
#
# @since 0.1.0
#
# @example Hardcoded value (String)
# require 'hanami/mailer'
#
# class WelcomeMailer
# include Hanami::Mailer
#
# to "user@example.com"
# end
#
# @example Hardcoded value (Array)
# require 'hanami/mailer'
#
# class WelcomeMailer
# include Hanami::Mailer
#
# to ["user-1@example.com", "user-2@example.com"]
# end
#
# @example Method (Symbol)
# require 'hanami/mailer'
#
# class WelcomeMailer
# include Hanami::Mailer
# to :email_address
#
# private
#
# def email_address
# user.email
# end
# end
#
# user = User.new(name: 'L')
# WelcomeMailer.deliver(user: user)
#
# @example Method that returns a collection of recipients
# require 'hanami/mailer'
#
# class WelcomeMailer
# include Hanami::Mailer
# to :recipients
#
# private
#
# def recipients
# users.map(&:email)
# end
# end
#
# users = [User.new(name: 'L'), User.new(name: 'MG')]
# WelcomeMailer.deliver(users: users)
def to(value = nil)
if value.nil?
@to
else
@to = value
end
end
# Sets the subject for mail messages
#
# It accepts a hardcoded value as a string, or a symbol that represents
@ -431,28 +302,19 @@ module Hanami
# @example Hardcoded value (String)
# require 'hanami/mailer'
#
# class WelcomeMailer
# include Hanami::Mailer
#
# class WelcomeMailer < Hanami::Mailer
# subject "Welcome"
# end
#
# @example Method (Symbol)
# @example Lazy value (Proc)
# require 'hanami/mailer'
#
# class WelcomeMailer
# include Hanami::Mailer
# subject :greeting
#
# private
#
# def greeting
# "Hello, #{ user.name }"
# end
# class WelcomeMailer < Hanami::Mailer
# subject ->(locals) { "Hello #{locals.fetch(:user).name}" }
# end
#
# user = User.new(name: 'L')
# WelcomeMailer.deliver(user: user)
# WelcomeMailer.new(configuration: configuration).deliver(user: user)
def subject(value = nil)
if value.nil?
@subject
@ -461,17 +323,70 @@ module Hanami
end
end
protected
# Loading mechanism hook.
# Set the template name **IF** it differs from the naming convention.
#
# For a given mailer named `Signup::Welcome` it will look for
# `signup/welcome.*.*` templates under the root directory.
#
# If for some reason, we need to specify a different template name, we can
# use this method.
#
# @param value [String] the template name
#
# @api private
# @since 0.1.0
# @api unstable
#
# @see Hanami::Mailer.load!
def load!
templates.freeze
configuration.freeze
# @example Custom template name
# require 'hanami/mailer'
#
# class MyMailer < Hanami::Mailer
# template 'mailer'
# end
def template(value)
@template = value
end
# @since next
# @api unstable
def template_name
@template || name
end
# Before callback for email delivery
#
# @since next
# @api unstable
#
# @example
# require 'hanami/mailer'
#
# module Billing
# class InvoiceMailer < Hanami::Mailer
# subject 'Invoice'
# from 'noreply@example.com'
# to ->(locals) { locals.fetch(:user).email }
#
# before do |mail, locals|
# user = locals.fetch(:user)
# mail.attachments["invoice-#{invoice_code}-#{user.id}.pdf"] = File.read('/path/to/invoice.pdf')
# end
#
# def invoice_code
# "123"
# end
# end
# end
#
# invoice = Invoice.new
# user = User.new(name: 'L', email: 'user@example.com')
#
# InvoiceMailer.new(configuration: configuration).deliver(invoice: invoice, user: user)
def before(&blk)
if block_given?
@before = blk
else
@before
end
end
end
end

View File

@ -0,0 +1,32 @@
# frozen_string_literal: true
require 'mail'
# require 'ice_nine'
module Hanami
class Mailer
# @since next
# @api unstable
class Finalizer
# Finalize the given configuration before to start to use the mailers
#
# @param mailers [Array<Hanami::Mailer>] all the subclasses of
# `Hanami::Mailer`
# @param configuration [Hanami::Mailer::Configuration] the configuration
# to finalize
#
# @return configuration [Hanami::Mailer::Configuration] the finalized
# configuration
#
# @since next
# @api unstable
def self.finalize(mailers, configuration)
Mail.eager_autoload!
mailers.each { |mailer| configuration.add_mailer(mailer) }
configuration.freeze
configuration
end
end
end
end

View File

@ -1,53 +0,0 @@
require 'hanami/utils/string'
module Hanami
module Mailer
module Rendering
# @since 0.1.0
# @api private
#
# TODO this is identical to Hanami::View, consider to move into Hanami::Utils
class TemplateName
# @since 0.1.0
# @api private
NAMESPACE_SEPARATOR = '::'.freeze
# @since 0.1.0
# @api private
def initialize(name, namespace)
@name = name
compile!(namespace)
end
# @since 0.1.0
# @api private
def to_s
@name
end
private
# @since 0.1.0
# @api private
def compile!(namespace)
tokens(namespace) { |token| replace!(token) }
@name = Utils::String.underscore(@name)
end
# @since 0.1.0
# @api private
def tokens(namespace)
namespace.to_s.split(NAMESPACE_SEPARATOR).each do |token|
yield token
end
end
# @since 0.1.0
# @api private
def replace!(token)
@name.gsub!(/\A#{token}#{NAMESPACE_SEPARATOR}/, '')
end
end
end
end
end

View File

@ -1,133 +0,0 @@
require 'hanami/mailer/template'
module Hanami
module Mailer
module Rendering
# Find templates for a mailer
#
# @api private
# @since 0.1.0
#
# @see Mailer::Template
class TemplatesFinder
# Default format
#
# @api private
# @since 0.1.0
FORMAT = '*'.freeze
# Default template engines
#
# @api private
# @since 0.1.0
ENGINES = '*'.freeze
# Recursive pattern
#
# @api private
# @since 0.1.0
RECURSIVE = '**'.freeze
# Initialize a finder
#
# @param mailer [Class] the mailer class
#
# @api private
# @since 0.1.0
def initialize(mailer)
@mailer = mailer
end
# Find all the associated templates to the mailer.
# It recursively looks for templates under the root path of the mailer,
# that are matching the template name
#
# @return [Hash] the templates
#
# @api private
# @since 0.1.0
#
# @see Hanami::Mailer::Dsl#root
# @see Hanami::Mailer::Dsl#templates
#
# @example
# require 'hanami/mailer'
#
# module Mailers
# class Welcome
# include Hanami::Mailer
# end
# end
#
# Mailers::Welcome.root # => "/path/to/templates"
# Mailers::Welcome.templates # => {[:html] => "welcome"}
#
# # This mailer has a template:
# #
# # "/path/to/templates/welcome.html.erb"
#
# Hanami::Mailer::Rendering::TemplatesFinder.new(Mailers::Welcome).find
# # => [#<Hanami::Mailer::Template:0x007f8a0a86a970 ... @file="/path/to/templates/welcome.html.erb">]
def find
templates = Hash[]
_find.map do |template|
name = File.basename(template)
format = (name.split('.')[-2]).to_sym
templates[format] = Mailer::Template.new(template)
end
templates
end
protected
# @api private
# @since 0.1.0
def _find(lookup = search_path)
Dir.glob("#{[root, lookup, template_name].join(separator)}.#{format}.#{engines}")
end
# @api private
# @since 0.1.0
def template_name
Rendering::TemplateName.new(@mailer.template, @mailer.configuration.namespace).to_s
end
# @api private
# @since 0.1.0
def root
@mailer.configuration.root
end
# @api private
# @since 0.1.0
def search_path
recursive
end
# @api private
# @since 0.1.0
def recursive
RECURSIVE
end
# @api private
# @since 0.1.0
def separator
::File::SEPARATOR
end
# @api private
# @since 0.1.0
def format
FORMAT
end
# @api private
# @since 0.1.0
def engines
ENGINES
end
end
end
end
end

View File

@ -1,7 +1,9 @@
# frozen_string_literal: true
require 'tilt'
module Hanami
module Mailer
class Mailer
# A logic-less template.
#
# @api private
@ -11,29 +13,20 @@ module Hanami
class Template
def initialize(template)
@_template = Tilt.new(template)
freeze
end
# Render the template within the context of the given scope.
#
# @param scope [Class] the rendering scope
# @param scope [Object] the rendering scope
# @param locals [Hash] set of objects passed to the constructor
#
# @return [String] the output of the rendering process
#
# @api private
# @since 0.1.0
def render(scope = Object.new, locals = {})
@_template.render(scope, locals)
end
# Get the path to the template
#
# @return [String] the pathname
#
# @api private
# @since 0.1.0
def file
@_template.file
def render(scope, locals = {})
@_template.render(scope.dup, locals)
end
end
end

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
require 'hanami/utils/string'
module Hanami
class Mailer
# @since 0.1.0
# @api private
#
# TODO this is identical to Hanami::View, consider to move into Hanami::Utils
class TemplateName
# @since next
# @api unstable
def self.call(name, namespace)
Utils::String.new(name.gsub(/\A#{namespace}(::)*/, '')).underscore.to_s
end
class << self
# @since next
# @api unstable
alias [] call
end
end
end
end

View File

@ -0,0 +1,152 @@
# frozen_string_literal: true
require 'hanami/mailer/template'
require 'hanami/utils/file_list'
require 'pathname'
module Hanami
class Mailer
# Find templates for a mailer
#
# @api private
# @since 0.1.0
#
# @see Mailer::Template
class TemplatesFinder
# Default format
#
# @api private
# @since 0.1.0
FORMAT = '*'
# Default template engines
#
# @api private
# @since 0.1.0
ENGINES = '*'
# Recursive pattern
#
# @api private
# @since 0.1.0
RECURSIVE = '**'
# Format separator
#
# @api unstable
# @since next
#
# @example
# welcome.html.erb
FORMAT_SEPARATOR = '.'
private_constant(*constants(true))
# Initialize a finder
#
# @param root [String,Pathname] the root directory where to recursively
# look for templates
#
# @raise [Errno::ENOENT] if the directory doesn't exist
#
# @api unstable
# @since 0.1.0
def initialize(root)
@root = Pathname.new(root).realpath
freeze
end
# Find all the associated templates to the mailer.
#
# It starts under the root path and it **recursively** looks for templates
# that are matching the given template name.
#
# @param template_name [String] the template name
#
# @return [Hash] the templates
#
# @api unstable
# @since 0.1.0
#
# @example
# require 'hanami/mailer'
#
# module Mailers
# class Welcome < Hanami::Mailer
# end
# end
#
# configuration = Hanami::Mailer::Configuration.new do |config|
# config.root = "path/to/templates"
# end
#
# # This mailer has a template:
# #
# # "path/to/templates/welcome.html.erb"
#
# Hanami::Mailer::Rendering::TemplatesFinder.new(root).find("welcome")
# # => [#<Hanami::Mailer::Template:0x007f8a0a86a970 ... @file="path/to/templates/welcome.html.erb">]
def find(template_name)
templates(template_name).each_with_object({}) do |template, result|
format = extract_format(template)
result[format] = Mailer::Template.new(template)
end
end
protected
# @api unstable
# @since 0.1.0
def templates(template_name, lookup = search_path)
Utils::FileList["#{[root, lookup, template_name].join(separator)}#{format_separator}#{format}#{format_separator}#{engines}"]
end
# @api unstable
# @since 0.1.0
attr_reader :root
# @api private
# @since 0.1.0
def search_path
recursive
end
# @api private
# @since 0.1.0
def recursive
RECURSIVE
end
# @api private
# @since 0.1.0
def separator
::File::SEPARATOR
end
# @api private
# @since 0.1.0
def format
FORMAT
end
# @api private
# @since 0.1.0
def engines
ENGINES
end
# @api unstable
# @since next
def format_separator
FORMAT_SEPARATOR
end
# @api unstable
# @since next
def extract_format(template)
filename = File.basename(template)
filename.split(format_separator)[-2].to_sym
end
end
end
end

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true
module Hanami
module Mailer
class Mailer
# @since 0.1.0
VERSION = '1.1.0'.freeze
VERSION = '1.1.0'
end
end

View File

@ -0,0 +1,154 @@
# frozen_string_literal: true
RSpec.describe Hanami::Mailer do
describe '.deliver' do
it 'can deliver with specified charset' do
mail = CharsetMailer.new(configuration: configuration).deliver(charset: charset = 'iso-2022-jp')
expect(mail.charset).to eq(charset)
expect(mail.parts.first.charset).to eq(charset)
end
it "raises error when 'from' isn't specified" do
expect { MissingFromMailer.new(configuration: configuration).deliver({}) }.to raise_error(Hanami::Mailer::MissingDeliveryDataError)
end
it "raises error when 'to' isn't specified" do
expect { MissingToMailer.new(configuration: configuration).deliver({}) }.to raise_error(Hanami::Mailer::MissingDeliveryDataError)
end
describe 'test delivery with hardcoded values' do
subject { WelcomeMailer.new(configuration: configuration).deliver({}) }
it 'sends the correct information' do
expect(subject.from).to eq(['noreply@sender.com'])
expect(subject.to).to eq(['noreply@recipient.com', 'owner@recipient.com'])
expect(subject.cc).to eq(['cc@recipient.com'])
expect(subject.bcc).to eq(['bcc@recipient.com'])
expect(subject.subject).to eq('Welcome')
end
it 'has the correct templates' do
expect(subject.html_part.to_s).to include(%(template))
expect(subject.text_part.to_s).to include(%(template))
end
it 'interprets the prepare statement' do
attachment = subject.attachments['invoice.pdf']
expect(attachment).to be_kind_of(Mail::Part)
expect(attachment).to be_attachment
expect(attachment).to_not be_inline
expect(attachment).to_not be_multipart
expect(attachment.filename).to eq('invoice.pdf')
expect(attachment.content_type).to match('application/pdf')
expect(attachment.content_type).to match('filename=invoice.pdf')
end
end
describe 'test delivery with procs' do
subject { ProcMailer.new(configuration: configuration).deliver(user: user) }
let(:user) { User.new('Name', 'student@deigirls.com') }
it 'sends the correct information' do
expect(subject.from).to eq(["hello-#{user.name.downcase}@example.com"])
expect(subject.to).to eq([user.email])
expect(subject.subject).to eq("[Hanami] Hello, #{user.name}")
end
end
describe 'test delivery with locals' do
subject { EventMailer.new(configuration: configuration) }
let(:count) { 100 }
it 'delivers the message' do
threads = []
mails = {}
count.times do |i|
threads << Thread.new do
user = OpenStruct.new(name: "Luca #{i}", email: "luca-#{i}@domain.test")
event = OpenStruct.new(id: i, title: "Event ##{i}")
mails[i] = subject.deliver(user: user, event: event)
end
end
threads.map(&:join)
expect(mails.count).to eq(count)
mails.each do |i, mail|
expect(mail.to).to eq(["luca-#{i}@domain.test"])
expect(mail.subject).to eq("Invitation: Event ##{i}")
expect(mail.attachments[0].filename).to eq("invitation-#{i}.ics")
end
end
end
describe 'multipart' do
it 'delivers all the parts by default' do
mail = WelcomeMailer.new(configuration: configuration).deliver({})
body = mail.body.encoded
expect(body).to include(%(<h1>Hello World!</h1>))
expect(body).to include(%(This is a txt template))
end
it 'can deliver only the text part' do
mail = WelcomeMailer.new(configuration: configuration).deliver(format: :txt)
body = mail.body.encoded
expect(body).to_not include(%(<h1>Hello World!</h1>))
expect(body).to include(%(This is a txt template))
end
it 'can deliver only the html part' do
mail = WelcomeMailer.new(configuration: configuration).deliver(format: :html)
body = mail.body.encoded
expect(body).to include(%(<h1>Hello World!</h1>))
expect(body).to_not include(%(This is a txt template))
end
end
describe 'custom delivery' do
before do
mailer.deliver({})
end
subject { options.fetch(:deliveries).first }
let(:mailer) { WelcomeMailer.new(configuration: configuration) }
let(:options) { { deliveries: [] } }
let(:configuration) do
configuration = Hanami::Mailer::Configuration.new do |config|
config.root = 'spec/support/fixtures'
config.delivery_method = MandrillDeliveryMethod, options
end
Hanami::Mailer.finalize(configuration)
end
it 'delivers the mail' do
expect(options.fetch(:deliveries).size).to be(1)
end
it 'sends the correct information' do
expect(subject.from).to eq(['noreply@sender.com'])
expect(subject.to).to eq(['noreply@recipient.com', 'owner@recipient.com'])
expect(subject.subject).to eq("Welcome")
end
it 'has the correct templates' do
expect(subject.html_part.to_s).to include(%(template))
expect(subject.text_part.to_s).to include(%(template))
end
it 'runs the before callback' do
expect(subject.attachments['invoice.pdf']).to_not be(nil)
end
end
end
end

View File

@ -1,3 +1,5 @@
# frozen_string_literal: true
RSpec.configure do |config|
config.expect_with :rspec do |expectations|
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
@ -22,35 +24,9 @@ RSpec.configure do |config|
Kernel.srand config.seed
end
require 'ostruct'
require 'hanami/utils'
$LOAD_PATH.unshift 'lib'
require 'hanami/mailer'
Hanami::Mailer.configure do
root Pathname.new __dir__ + '/support/fixtures/templates'
end
Hanami::Mailer.class_eval do
def self.reset!
self.configuration = configuration.duplicate
configuration.reset!
end
end
Hanami::Mailer::Dsl.class_eval do
def reset!
@templates = {}
end
end
Hanami::Utils.require!("spec/support")
Hanami::Mailer.configure do
root __dir__ + '/support/fixtures'
delivery_method :test
prepare do
include DefaultSubject
end
end.load!

22
spec/support/context.rb Normal file
View File

@ -0,0 +1,22 @@
module RSpec
module Support
module Context
def self.included(base)
base.class_eval do
let(:configuration) do
configuration = Hanami::Mailer::Configuration.new do |config|
config.root = 'spec/support/fixtures'
config.delivery_method = :test
end
Hanami::Mailer.finalize(configuration)
end
end
end
end
end
end
RSpec.configure do |config|
config.include(RSpec::Support::Context)
end

View File

@ -1,42 +1,36 @@
class InvoiceMailer
include Hanami::Mailer
# frozen_string_literal: true
class InvoiceMailer < Hanami::Mailer
template 'invoice'
end
class RenderMailer
include Hanami::Mailer
class RenderMailer < Hanami::Mailer
end
class TemplateEngineMailer
include Hanami::Mailer
class TemplateEngineMailer < Hanami::Mailer
end
class CharsetMailer
include Hanami::Mailer
class CharsetMailer < Hanami::Mailer
from 'noreply@example.com'
to 'user@example.com'
subject 'こんにちは'
end
class MissingFromMailer
include Hanami::Mailer
class MissingFromMailer < Hanami::Mailer
template 'missing'
to 'recipient@example.com'
subject 'Hello'
end
class MissingToMailer
include Hanami::Mailer
class MissingToMailer < Hanami::Mailer
template 'missing'
from 'sender@example.com'
subject 'Hello'
end
class CcOnlyMailer
include Hanami::Mailer
class CcOnlyMailer < Hanami::Mailer
template 'missing'
cc 'recipient@example.com'
@ -44,8 +38,7 @@ class CcOnlyMailer
subject 'Hello'
end
class BccOnlyMailer
include Hanami::Mailer
class BccOnlyMailer < Hanami::Mailer
template 'missing'
bcc 'recipient@example.com'
@ -55,35 +48,20 @@ end
User = Struct.new(:name, :email)
class LazyMailer
include Hanami::Mailer
class LazyMailer < Hanami::Mailer
end
class MethodMailer
include Hanami::Mailer
class ProcMailer < Hanami::Mailer
from ->(locals) { "hello-#{locals.fetch(:user).name.downcase}@example.com" }
to ->(locals) { locals.fetch(:user).email }
subject ->(locals) { "[Hanami] #{locals.fetch(:greeting)}" }
from :sender
to :recipient
subject :greeting
def greeting
"Hello, #{user.name}"
end
private
def sender
"hello-#{user.name.downcase}@example.com"
end
def recipient
user.email
before do |_, locals|
locals[:greeting] = "Hello, #{locals.fetch(:user).name}"
end
end
class WelcomeMailer
include Hanami::Mailer
class WelcomeMailer < Hanami::Mailer
from 'noreply@sender.com'
to ['noreply@recipient.com', 'owner@recipient.com']
cc 'cc@recipient.com'
@ -91,12 +69,34 @@ class WelcomeMailer
subject 'Welcome'
before do |mail|
mail.attachments['invoice.pdf'] = "/path/to/invoice-#{invoice_code}.pdf"
end
def greeting
'Ahoy'
end
def prepare
mail.attachments['invoice.pdf'] = '/path/to/invoice.pdf'
def invoice_code
"123"
end
end
class EventMailer < Hanami::Mailer
from 'events@domain.test'
to ->(locals) { locals.fetch(:user).email }
subject ->(locals) { "Invitation: #{locals.fetch(:event).title}" }
before do |mail, locals|
mail.attachments["invitation-#{locals.fetch(:event).id}.ics"] = generate_invitation_attachment(locals)
end
private
# Simulate on-the-fly creation of an attachment file.
# For speed purposes we're not gonna create the file, but only return a path.
def generate_invitation_attachment(locals)
"invitation-#{locals.fetch(:event).id}.ics"
end
end
@ -111,8 +111,14 @@ class MandrillDeliveryMethod
end
module Users
class Welcome
include Hanami::Mailer
class Welcome < Hanami::Mailer
end
end
module Web
module Mailers
class SignupMailer < Hanami::Mailer
end
end
end

View File

@ -0,0 +1,2 @@
Fixture used by
spec/unit/hanami/mailer/templates_finder_spec.rb

View File

@ -0,0 +1,2 @@
Fixture used by
spec/unit/hanami/mailer/templates_finder_spec.rb

View File

@ -0,0 +1,2 @@
Fixture used by
spec/unit/hanami/mailer/templates_finder_spec.rb

View File

@ -1,21 +1,21 @@
RSpec.describe Hanami::Mailer::Configuration do
before do
@configuration = Hanami::Mailer::Configuration.new
end
# frozen_string_literal: true
describe '#root' do
RSpec.describe Hanami::Mailer::Configuration do
subject { described_class.new }
describe '#root=' do
describe 'when a value is given' do
describe 'and it is a string' do
it 'sets it as a Pathname' do
@configuration.root 'spec'
expect(@configuration.root).to eq(Pathname.new('spec').realpath)
subject.root = 'spec'
expect(subject.root).to eq(Pathname.new('spec').realpath)
end
end
describe 'and it is a pathname' do
it 'sets it' do
@configuration.root Pathname.new('spec')
expect(@configuration.root).to eq(Pathname.new('spec').realpath)
subject.root = Pathname.new('spec')
expect(subject.root).to eq(Pathname.new('spec').realpath)
end
end
@ -33,15 +33,15 @@ RSpec.describe Hanami::Mailer::Configuration do
end
it 'sets the converted value' do
@configuration.root RootPath.new('spec')
expect(@configuration.root).to eq(Pathname.new('spec').realpath)
subject.root = RootPath.new('spec')
expect(subject.root).to eq(Pathname.new('spec').realpath)
end
end
describe 'and it is an unexisting path' do
it 'raises an error' do
expect do
@configuration.root '/path/to/unknown'
subject.root = '/path/to/unknown'
end.to raise_error(Errno::ENOENT)
end
end
@ -49,146 +49,69 @@ RSpec.describe Hanami::Mailer::Configuration do
describe 'when a value is not given' do
it 'defaults to the current path' do
expect(@configuration.root).to eq(Pathname.new('.').realpath)
expect(subject.root).to eq(Pathname.new('.').realpath)
end
end
end
describe '#mailers' do
it 'defaults to an empty set' do
expect(@configuration.mailers).to be_empty
end
it 'allows to add mailers' do
@configuration.add_mailer(InvoiceMailer)
expect(@configuration.mailers).to include(InvoiceMailer)
end
it 'eliminates duplications' do
@configuration.add_mailer(RenderMailer)
@configuration.add_mailer(RenderMailer)
expect(@configuration.mailers.size).to eq(1)
end
end
describe '#prepare' do
before do
module FooRendering
def render
'foo'
end
end
class PrepareMailer
end
end
after do
Object.__send__(:remove_const, :FooRendering)
Object.__send__(:remove_const, :PrepareMailer)
end
it 'raises error in case of missing block' do
expect { @configuration.prepare }.to raise_error(ArgumentError, 'Please provide a block')
end
end
# describe '#reset!' do
# before do
# @configuration.root 'test'
# @configuration.add_mailer(InvoiceMailer)
# @configuration.reset!
# end
# it 'resets root' do
# root = Pathname.new('.').realpath
# @configuration.root.must_equal root
# @configuration.mailers.must_be_empty
# end
# it "doesn't reset namespace" do
# @configuration.namespace(InvoiceMailer)
# @configuration.reset!
# @configuration.namespace.must_equal(InvoiceMailer)
# end
# end
describe '#load!' do
before do
@configuration.root 'spec'
@configuration.load!
end
it 'loads root' do
root = Pathname.new('spec').realpath
expect(@configuration.root).to eq(root)
end
end
describe '#delivery_method' do
describe 'when not previously set' do
before do
@configuration.reset!
end
it 'defaults to SMTP' do
expect(@configuration.delivery_method).to eq([:smtp, {}])
expect(subject.delivery_method).to eq(:smtp)
end
end
describe 'set with a symbol' do
before do
@configuration.delivery_method :exim, location: '/path/to/exim'
subject.delivery_method = :exim, { location: '/path/to/exim' }
end
it 'saves the delivery method in the configuration' do
expect(@configuration.delivery_method).to eq([:exim, { location: '/path/to/exim' }])
expect(subject.delivery_method).to eq([:exim, { location: '/path/to/exim' }])
end
end
describe 'set with a class' do
before do
@configuration.delivery_method MandrillDeliveryMethod,
username: 'mandrill-username', password: 'mandrill-api-key'
subject.delivery_method = MandrillDeliveryMethod,
{ username: 'mandrill-username', password: 'mandrill-api-key' }
end
it 'saves the delivery method in the configuration' do
expect(@configuration.delivery_method).to eq([MandrillDeliveryMethod, username: 'mandrill-username', password: 'mandrill-api-key'])
expect(subject.delivery_method).to eq([MandrillDeliveryMethod, username: 'mandrill-username', password: 'mandrill-api-key'])
end
end
end
describe '#default_charset' do
describe 'when not previously set' do
before do
@configuration.reset!
end
it 'defaults to UTF-8' do
expect(@configuration.default_charset).to eq('UTF-8')
expect(subject.default_charset).to eq('UTF-8')
end
end
describe 'when set' do
before do
@configuration.default_charset 'iso-8859-1'
subject.default_charset = 'iso-8859-1'
end
it 'saves the delivery method in the configuration' do
expect(@configuration.default_charset).to eq('iso-8859-1')
expect(subject.default_charset).to eq('iso-8859-1')
end
end
end
describe '#prepare' do
it 'injects code in each mailer'
# it 'injects code in each mailer' do
# InvoiceMailer.subject.must_equal 'default subject'
# end
describe "#freeze" do
before do
subject.freeze
end
it "is frozen" do
expect(subject).to be_frozen
end
it "raises error if trying to add a mailer" do
expect { subject.add_mailer(WelcomeMailer) }.to raise_error(RuntimeError)
end
end
end

View File

@ -1,184 +0,0 @@
RSpec.describe Hanami::Mailer do
describe '.deliver' do
before do
Hanami::Mailer.deliveries.clear
end
it 'can deliver with specified charset' do
CharsetMailer.deliver(charset: charset = 'iso-2022-jp')
mail = Hanami::Mailer.deliveries.first
expect(mail.charset).to eq(charset)
expect(mail.parts.first.charset).to eq(charset)
end
it "raises error when 'from' isn't specified" do
expect { MissingFromMailer.deliver }.to raise_error(Hanami::Mailer::MissingDeliveryDataError)
end
it "raises error when 'to' isn't specified" do
expect { MissingToMailer.deliver }.to raise_error(Hanami::Mailer::MissingDeliveryDataError)
end
it "doesn't raise error when 'cc' is specified but 'to' isn't" do
CcOnlyMailer.deliver
end
it "doesn't raise error when 'bcc' is specified but 'to' isn't" do
BccOnlyMailer.deliver
end
it "lets other errors to bubble up" do
mailer = CharsetMailer.new({})
mail = Class.new do
def deliver
raise ArgumentError, "ouch"
end
end.new
expect(mailer).to receive(:mail).and_return(mail)
expect { mailer.deliver }.to raise_error(ArgumentError, "ouch")
end
describe 'test delivery with hardcoded values' do
before do
WelcomeMailer.deliver
@mail = Hanami::Mailer.deliveries.first
end
after do
Hanami::Mailer.deliveries.clear
end
it 'delivers the mail' do
expect(Hanami::Mailer.deliveries.length).to eq(1)
end
it 'sends the correct information' do
expect(@mail.from).to eq(['noreply@sender.com'])
expect(@mail.to).to eq(['noreply@recipient.com', 'owner@recipient.com'])
expect(@mail.cc).to eq(['cc@recipient.com'])
expect(@mail.bcc).to eq(['bcc@recipient.com'])
expect(@mail.subject).to eq('Welcome')
end
it 'has the correct templates' do
expect(@mail.html_part.to_s).to include(%(template))
expect(@mail.text_part.to_s).to include(%(template))
end
it 'interprets the prepare statement' do
attachment = @mail.attachments['invoice.pdf']
expect(attachment).to be_kind_of(Mail::Part)
expect(attachment).to be_attachment
expect(attachment).to_not be_inline
expect(attachment).to_not be_multipart
expect(attachment.filename).to eq('invoice.pdf')
expect(attachment.content_type).to match('application/pdf')
expect(attachment.content_type).to match('filename=invoice.pdf')
end
end
describe 'test delivery with methods' do
before do
@user = User.new('Name', 'student@deigirls.com')
MethodMailer.deliver(user: @user)
@mail = Hanami::Mailer.deliveries.first
end
after do
Hanami::Mailer.deliveries.clear
end
it 'delivers the mail' do
expect(Hanami::Mailer.deliveries.length).to eq(1)
end
it 'sends the correct information' do
expect(@mail.from).to eq(["hello-#{@user.name.downcase}@example.com"])
expect(@mail.to).to eq([@user.email])
expect(@mail.subject).to eq("Hello, #{@user.name}")
end
end
describe 'multipart' do
after do
Hanami::Mailer.deliveries.clear
end
it 'delivers all the parts by default' do
WelcomeMailer.deliver
mail = Hanami::Mailer.deliveries.first
body = mail.body.encoded
expect(body).to include(%(<h1>Hello World!</h1>))
expect(body).to include(%(This is a txt template))
end
it 'can deliver only the text part' do
WelcomeMailer.deliver(format: :txt)
mail = Hanami::Mailer.deliveries.first
body = mail.body.encoded
expect(body).to_not include(%(<h1>Hello World!</h1>))
expect(body).to include(%(This is a txt template))
end
it 'can deliver only the html part' do
WelcomeMailer.deliver(format: :html)
mail = Hanami::Mailer.deliveries.first
body = mail.body.encoded
expect(body).to include(%(<h1>Hello World!</h1>))
expect(body).to_not include(%(This is a txt template))
end
end
describe 'custom delivery' do
before do
@options = options = { deliveries: [] }
# Hanami::Mailer.reset!
# Hanami::Mailer.configure do
# delivery_method MandrillDeliveryMethod, options
# end.load!
WelcomeMailer.deliver
@mail = options.fetch(:deliveries).first
end
it 'delivers the mail'
# it 'delivers the mail' do
# @options.fetch(:deliveries).length.must_equal 1
# end
# it 'sends the correct information' do
# @mail.from.must_equal ['noreply@sender.com']
# @mail.to.must_equal ['noreply@recipient.com']
# @mail.subject.must_equal "Welcome"
# end
# it 'has the correct templates' do
# @mail.html_part.to_s.must_include %(template)
# @mail.text_part.to_s.must_include %(template)
# end
# it 'interprets the prepare statement' do
# @mail.attachments['invoice.pdf'].wont_be_nil
# end
# it 'adds the attachment to the mail object' do
# @mail.attachments['render_mailer.html.erb'].wont_be_nil
# end
end
end
end

View File

@ -1,47 +1,123 @@
RSpec.describe Hanami::Mailer do
describe '.template' do
describe 'when no value is set' do
it 'returns the convention name' do
expect(RenderMailer.template).to eq('render_mailer')
end
# frozen_string_literal: true
it 'returns correct namespaced value' do
expect(Users::Welcome.template).to eq('users/welcome')
end
RSpec.describe Hanami::Mailer::Dsl do
let(:mailer) { Class.new { extend Hanami::Mailer::Dsl } }
describe '.from' do
it 'returns the default value' do
expect(mailer.from).to be(nil)
end
describe 'when a value is set' do
it 'returns that name' do
expect(InvoiceMailer.template).to eq('invoice')
end
it 'sets the value' do
sender = 'sender@hanami.test'
mailer.from sender
expect(mailer.from).to eq(sender)
end
end
describe '.templates' do
describe 'when no value is set' do
it 'returns a set of templates' do
template_formats = LazyMailer.templates.keys
expect(template_formats).to eq(%i[html txt])
end
it 'returns only the template for the given format' do
template = LazyMailer.templates(:txt)
expect(template).to be_kind_of(Hanami::Mailer::Template)
expect(template.file).to match(%r{spec/support/fixtures/templates/lazy_mailer.txt.erb\z})
end
describe '.to' do
it 'returns the default value' do
expect(mailer.to).to be(nil)
end
describe 'when a value is set' do
it 'returns a set of templates' do
template_formats = InvoiceMailer.templates.keys
expect(template_formats).to eq([:html])
end
it 'sets a single value' do
recipient = 'recipient@hanami.test'
mailer.to recipient
it 'returns only the template for the given format' do
template = InvoiceMailer.templates(:html)
expect(template).to be_kind_of(Hanami::Mailer::Template)
expect(template.file).to match(%r{spec/support/fixtures/templates/invoice.html.erb\z})
end
expect(mailer.to).to eq(recipient)
end
it 'sets an array of values' do
recipients = ['recipient@hanami.test']
mailer.to recipients
expect(mailer.to).to eq(recipients)
end
end
describe '.cc' do
it 'returns the default value' do
expect(mailer.cc).to be(nil)
end
it 'sets a single value' do
recipient = 'cc@hanami.test'
mailer.cc recipient
expect(mailer.cc).to eq(recipient)
end
it 'sets an array of values' do
recipients = ['cc@hanami.test']
mailer.cc recipients
expect(mailer.cc).to eq(recipients)
end
end
describe '.bcc' do
it 'returns the default value' do
expect(mailer.bcc).to be(nil)
end
it 'sets a single value' do
recipient = 'bcc@hanami.test'
mailer.bcc recipient
expect(mailer.bcc).to eq(recipient)
end
it 'sets an array of values' do
recipients = ['bcc@hanami.test']
mailer.bcc recipients
expect(mailer.bcc).to eq(recipients)
end
end
describe '.subject' do
it 'returns the default value' do
expect(mailer.subject).to be(nil)
end
it 'sets a value' do
mail_subject = 'Hello'
mailer.subject mail_subject
expect(mailer.subject).to eq(mail_subject)
end
end
describe '.template' do
it 'sets a value' do
mailer.template 'file'
end
end
describe '.template_name' do
it 'returns the default value' do
expect(mailer.template_name).to be(nil)
end
it 'returns value, if set' do
template = 'file'
mailer.template template
expect(mailer.template_name).to eq(template)
end
end
describe '.before' do
it 'returns the default value' do
expect(mailer.before).to be_kind_of(Proc)
end
it 'sets a value' do
blk = ->(*) {}
mailer.before(&blk)
expect(mailer.before).to eq(blk)
end
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
RSpec.describe Hanami::Mailer::Error do
it "inherits from StandardError" do
expect(described_class.ancestors).to include(StandardError)
end
end

View File

@ -0,0 +1,28 @@
# frozen_string_literal: true
RSpec.describe Hanami::Mailer::Finalizer do
let(:configuration) do
Hanami::Mailer::Configuration.new do |config|
config.root = 'spec/support/fixtures'
end
end
let(:mailers) { [double('mailer', template_name: "invoice")] }
describe '.finalize' do
it 'eager autoloads modules from mail gem' do
expect(Mail).to receive(:eager_autoload!)
described_class.finalize(mailers, configuration)
end
it "adds the mailer to the configuration" do
expect(configuration).to receive(:add_mailer).with(mailers.first)
described_class.finalize(mailers, configuration)
end
it "returns frozen configuration" do
actual = described_class.finalize(mailers, configuration)
expect(actual).to be_frozen
end
end
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
RSpec.describe Hanami::Mailer::MissingDeliveryDataError do
it "inherits from Hanami::Error" do
expect(described_class.ancestors).to include(Hanami::Mailer::Error)
end
it "has a custom error message" do
expect { raise described_class }.to raise_error(described_class, "Missing delivery data, please check 'from', or 'to'")
end
end

View File

@ -1,44 +0,0 @@
RSpec.describe Hanami::Mailer do
describe '#render' do
describe 'when template is explicitly declared' do
let(:mailer) { InvoiceMailer.new }
it 'renders the given template' do
expect(mailer.render(:html)).to include(%(<h1>Invoice template</h1>))
end
end
describe 'when template is implicitly declared' do
let(:mailer) { LazyMailer.new }
it 'looks for template with same name with inflected classname and render it' do
expect(mailer.render(:html)).to include(%(Hello World))
expect(mailer.render(:txt)).to include(%(This is a txt template))
end
end
describe 'when mailer defines context' do
let(:mailer) { WelcomeMailer.new }
it 'renders template with defined context' do
expect(mailer.render(:txt)).to include(%(Ahoy))
end
end
describe 'when locals are parsed in' do
let(:mailer) { RenderMailer.new(user: User.new('Luca')) }
it 'renders template with parsed locals' do
expect(mailer.render(:html)).to include(%(Luca))
end
end
describe 'with HAML template engine' do
let(:mailer) { TemplateEngineMailer.new(user: User.new('Luca')) }
it 'renders template with parsed locals' do
expect(mailer.render(:html)).to include(%(<h1>\nLuca\n</h1>\n))
end
end
end
end

View File

@ -0,0 +1,67 @@
# frozen_string_literal: true
RSpec.describe Hanami::Mailer::TemplateName do
describe ".call" do
context "with top level namespace" do
let(:namespace) { Object }
it "returns an instance of ::String" do
template_name = described_class.call("template", namespace)
expect(template_name).to be_kind_of(String)
end
it "returns name from plain name" do
template_name = described_class.call("template", namespace)
expect(template_name).to eq("template")
end
it "returns name from camel case name" do
template_name = described_class.call("ATemplate", namespace)
expect(template_name).to eq("a_template")
end
it "returns name from snake case name" do
template_name = described_class.call("a_template", namespace)
expect(template_name).to eq("a_template")
end
it "returns name from modulized name" do
template_name = described_class.call("Mailers::WelcomeMailer", namespace)
expect(template_name).to eq("mailers/welcome_mailer")
end
it "returns name from class" do
template_name = described_class.call(InvoiceMailer.name, namespace)
expect(template_name).to eq("invoice_mailer")
end
it "returns name from modulized class" do
template_name = described_class.call(Users::Welcome.name, namespace)
expect(template_name).to eq("users/welcome")
end
it "returns blank string from blank name" do
template_name = described_class.call("", namespace)
expect(template_name).to eq("")
end
it "raises error with nil name" do
expect { described_class.call(nil, namespace) }.to raise_error(NoMethodError)
end
end
context "with application namespace" do
let(:namespace) { Web::Mailers }
it "returns name from class name" do
template_name = described_class.call("SignupMailer", namespace)
expect(template_name).to eq("signup_mailer")
end
it "returns name from modulized class name" do
template_name = described_class.call(Web::Mailers::SignupMailer.name, namespace)
expect(template_name).to eq("signup_mailer")
end
end
end
end

View File

@ -0,0 +1,46 @@
# frozen_string_literal: true
RSpec.describe Hanami::Mailer::Template do
subject { described_class.new(file) }
let(:file) { "spec/support/fixtures/templates/welcome_mailer.txt.erb" }
describe "#initialize" do
context "with existing file" do
it "instantiates template" do
expect(subject).to be_kind_of(described_class)
end
it "initialize frozen instance" do
expect(subject).to be_frozen
end
end
context "with missing template engine" do
it "returns error" do
expect { described_class.new("Gemfile") }.to raise_error(RuntimeError, "No template engine registered for Gemfile")
end
end
context "with unexisting file" do
it "returns error" do
expect { described_class.new("foo.erb") }.to raise_error(Errno::ENOENT)
end
end
end
describe "#render" do
it "renders template" do
scope = Object.new
actual = subject.render(scope, greeting: "Hello")
expect(actual).to eq("This is a txt template\nHello")
end
it "renders with unfrozen object" do
scope = Object.new
expect(scope).to receive(:dup)
subject.render(scope, greeting: "Hello")
end
end
end

View File

@ -0,0 +1,75 @@
# frozen_string_literal: true
RSpec.describe Hanami::Mailer::TemplatesFinder do
subject { described_class.new(root) }
# NOTE: please do not change this name, because `#find` specs are relying on
# template fixtures. See all the fixtures under: spec/support/fixtures/templates
let(:template_name) { "welcome_mailer" }
let(:root) { configuration.root }
describe "#initialize" do
context "with valid root" do
it "instantiates a new finder instance" do
expect(subject).to be_kind_of(described_class)
end
it "returns a frozen object" do
expect(subject).to be_frozen
end
end
context "with unexisting root" do
let(:root) { "path/to/unexisting" }
it "raises error" do
expect { subject }.to raise_error(Errno::ENOENT)
end
end
context "with nil root" do
let(:root) { nil }
it "raises error" do
expect { subject }.to raise_error(TypeError)
end
end
end
describe "#find" do
context "with valid template name" do
it "returns templates" do
actual = subject.find(template_name)
# It excludes all the files that aren't matching the convention:
#
# `<template_name>.<format>.<template_engine>`
#
# Under `spec/support/fixtures/templates` we have the following files:
#
# * welcome_mailer
# * welcome_mailer.erb
# * welcome_mailer.html
# * welcome_mailer.html.erb
# * welcome_mailer.txt.erb
#
# Only the last two are matching the pattern, here's why we have only
# two templates loaded.
expect(actual.keys).to eq(%i[html txt])
actual.each_value do |template|
expect(template).to be_kind_of(Hanami::Mailer::Template)
expect(template.instance_variable_get(:@_template).__send__(:file)).to match(%r{spec/support/fixtures/templates/welcome_mailer.(html|txt).erb})
end
end
end
context "with missing template" do
let(:template_name) { "missing_template" }
it "doesn't return templates" do
actual = subject.find(template_name)
expect(actual).to be_empty
end
end
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
RSpec.describe Hanami::Mailer::UnknownMailerError do
it "inherits from Hanami::Error" do
expect(described_class.ancestors).to include(Hanami::Mailer::Error)
end
it "has a custom error message" do
mailer = InvoiceMailer
expect { raise described_class.new(mailer) }.to raise_error(described_class, "Unknown mailer: #{mailer}. Please finalize the configuration before to use it.")
end
it "has explicit handling for nil" do
mailer = nil
expect { raise described_class.new(mailer) }.to raise_error(described_class, "Unknown mailer: #{mailer.inspect}. Please finalize the configuration before to use it.")
end
end

View File

@ -1,3 +1,5 @@
# frozen_string_literal: true
RSpec.describe "Hanami::Mailer::VERSION" do
it "returns current version" do
expect(Hanami::Mailer::VERSION).to eq("1.1.0")

View File

@ -0,0 +1,168 @@
# frozen_string_literal: true
RSpec.describe Hanami::Mailer do
context "constants" do
it "marks them as private" do
expect { described_class::CONTENT_TYPES }.to raise_error(NameError)
end
end
context ".finalize" do
let(:configuration) { Hanami::Mailer::Configuration.new }
it "finalizes the given configuration" do
actual = described_class.finalize(configuration)
expect(actual).to be_frozen
end
end
context "#initialize" do
let(:configuration) { Hanami::Mailer::Configuration.new }
it "builds an frozen instance" do
mailer = described_class.new(configuration: configuration)
expect(mailer).to be_frozen
end
it "prevents memoization mutations" do
mailer = Class.new(described_class) do
def self.name
"memoization_attempt"
end
def foo
@foo ||= "foo"
end
end.new(configuration: configuration)
expect { mailer.foo }.to raise_error(RuntimeError)
end
it "prevents accidental configuration removal" do
mailer = Class.new(described_class) do
def self.name
"configuration_removal"
end
def foo
@configuration = nil
end
end.new(configuration: configuration)
expect { mailer.foo }.to raise_error(RuntimeError)
end
end
context "#deliver" do
context "when mailer has from/to defined with DSL" do
let(:mailer) { CharsetMailer.new(configuration: configuration) }
it "delivers email with valid locals" do
mail = mailer.deliver({})
expect(mail).to be_kind_of(Mail::Message)
end
it "is aliased as #call" do
mail = mailer.call({})
expect(mail).to be_kind_of(Mail::Message)
end
it "raises error when locals are nil" do
expect { mailer.deliver(nil) }.to raise_error(NoMethodError)
end
end
context "when from/to are missing" do
let(:mailer) { InvoiceMailer.new(configuration: configuration) }
it "raises error" do
expect { mailer.deliver({}) }.to raise_error(Hanami::Mailer::MissingDeliveryDataError)
end
end
context "when using #{described_class} directly" do
let(:mailer) do
described_class.new(configuration: configuration)
end
it "raises error" do
expect { mailer.deliver({}) }.to raise_error(NoMethodError)
end
end
context "with non-finalized configuration" do
let(:configuration) { Hanami::Mailer::Configuration.new }
let(:mailer) do
Class.new(described_class) do
def self.name
"anonymous_mailer"
end
end.new(configuration: configuration)
end
it "raises error" do
expect { mailer.deliver({}) }.to raise_error(Hanami::Mailer::UnknownMailerError)
end
end
context "locals" do
let(:mailer) { EventMailer.new(configuration: configuration) }
let(:user) { OpenStruct.new(name: "Luca", email: "luca@domain.test") }
let(:event) { OpenStruct.new(id: 23, title: "Event #23") }
it "uses locals during the delivery process" do
mail = mailer.deliver(user: user, event: event)
expect(mail.to).to eq(["luca@domain.test"])
expect(mail.subject).to eq("Invitation: Event #23")
expect(mail.attachments[0].filename).to eq("invitation-23.ics")
end
end
end
describe '#render' do
describe 'when template is explicitly declared' do
let(:mailer) { InvoiceMailer.new(configuration: configuration) }
it 'renders the given template' do
expect(mailer.render(:html, {})).to include(%(<h1>Invoice template</h1>))
end
end
describe 'when template is implicitly declared' do
let(:mailer) { LazyMailer.new(configuration: configuration) }
it 'looks for template with same name with inflected classname and render it' do
expect(mailer.render(:html, {})).to include(%(Hello World))
expect(mailer.render(:txt, {})).to include(%(This is a txt template))
end
end
describe 'when mailer defines context' do
let(:mailer) { WelcomeMailer.new(configuration: configuration) }
it 'renders template with defined context' do
expect(mailer.render(:txt, {})).to include(%(Ahoy))
end
end
describe 'when locals are parsed in' do
let(:mailer) { RenderMailer.new(configuration: configuration) }
let(:locals) { { user: User.new('Luca') } }
it 'renders template with parsed locals' do
expect(mailer.render(:html, locals)).to include(locals.fetch(:user).name)
end
end
describe 'with HAML template engine' do
let(:mailer) { TemplateEngineMailer.new(configuration: configuration) }
let(:locals) { { user: User.new('MG') } }
it 'renders template with parsed locals' do
expect(mailer.render(:html, locals)).to include(%(<h1>\n#{locals.fetch(:user).name}\n</h1>\n))
end
end
end
end