From d2b8a32ade53f062b259142125dd6da02f069333 Mon Sep 17 00:00:00 2001 From: Luca Guidi Date: Wed, 29 Nov 2017 12:15:12 +0100 Subject: [PATCH] Remove global state. Immutable mailer. (#69) --- .rubocop.yml | 4 + Gemfile | 5 +- README.md | 155 +++--- benchmark.rb | 48 ++ config/flay.yml | 3 - config/flog.yml | 2 +- config/reek.yml | 43 +- config/rubocop.yml | 97 ---- examples/base.rb | 46 ++ examples/base/invoice.html.erb | 8 + examples/base/invoice.txt.erb | 1 + hanami-mailer.gemspec | 2 +- lib/hanami-mailer.rb | 1 - lib/hanami/mailer.rb | 436 +++++++---------- lib/hanami/mailer/configuration.rb | 265 +++++------ lib/hanami/mailer/dsl.rb | 441 +++++++----------- lib/hanami/mailer/finalizer.rb | 32 ++ lib/hanami/mailer/rendering/template_name.rb | 53 --- .../mailer/rendering/templates_finder.rb | 133 ------ lib/hanami/mailer/template.rb | 21 +- lib/hanami/mailer/template_name.rb | 25 + lib/hanami/mailer/templates_finder.rb | 152 ++++++ lib/hanami/mailer/version.rb | 6 +- .../hanami/mailer/delivery_spec.rb | 154 ++++++ spec/spec_helper.rb | 30 +- spec/support/context.rb | 22 + spec/support/fixtures.rb | 94 ++-- ...hod_mailer.txt.erb => proc_mailer.txt.erb} | 0 .../support/fixtures/templates/welcome_mailer | 2 + .../fixtures/templates/welcome_mailer.erb | 2 + .../fixtures/templates/welcome_mailer.html | 2 + spec/unit/hanami/mailer/configuration_spec.rb | 145 ++---- spec/unit/hanami/mailer/delivery_spec.rb | 184 -------- spec/unit/hanami/mailer/dsl_spec.rb | 146 ++++-- spec/unit/hanami/mailer/error_spec.rb | 7 + spec/unit/hanami/mailer/finalizer_spec.rb | 28 ++ .../missing_delivery_data_error_spec.rb | 11 + spec/unit/hanami/mailer/rendering_spec.rb | 44 -- spec/unit/hanami/mailer/template_name_spec.rb | 67 +++ spec/unit/hanami/mailer/template_spec.rb | 46 ++ .../hanami/mailer/templates_finder_spec.rb | 75 +++ .../mailer/unknown_mailer_error_spec.rb | 17 + spec/unit/hanami/mailer/version_spec.rb | 2 + spec/unit/hanami/mailer_spec.rb | 168 +++++++ 44 files changed, 1725 insertions(+), 1500 deletions(-) create mode 100644 benchmark.rb delete mode 100644 config/flay.yml delete mode 100644 config/rubocop.yml create mode 100644 examples/base.rb create mode 100644 examples/base/invoice.html.erb create mode 100644 examples/base/invoice.txt.erb delete mode 100644 lib/hanami-mailer.rb create mode 100644 lib/hanami/mailer/finalizer.rb delete mode 100644 lib/hanami/mailer/rendering/template_name.rb delete mode 100644 lib/hanami/mailer/rendering/templates_finder.rb create mode 100644 lib/hanami/mailer/template_name.rb create mode 100644 lib/hanami/mailer/templates_finder.rb create mode 100644 spec/integration/hanami/mailer/delivery_spec.rb create mode 100644 spec/support/context.rb rename spec/support/fixtures/templates/{method_mailer.txt.erb => proc_mailer.txt.erb} (100%) create mode 100644 spec/support/fixtures/templates/welcome_mailer create mode 100644 spec/support/fixtures/templates/welcome_mailer.erb create mode 100644 spec/support/fixtures/templates/welcome_mailer.html delete mode 100644 spec/unit/hanami/mailer/delivery_spec.rb create mode 100644 spec/unit/hanami/mailer/error_spec.rb create mode 100644 spec/unit/hanami/mailer/finalizer_spec.rb create mode 100644 spec/unit/hanami/mailer/missing_delivery_data_error_spec.rb delete mode 100644 spec/unit/hanami/mailer/rendering_spec.rb create mode 100644 spec/unit/hanami/mailer/template_name_spec.rb create mode 100644 spec/unit/hanami/mailer/template_spec.rb create mode 100644 spec/unit/hanami/mailer/templates_finder_spec.rb create mode 100644 spec/unit/hanami/mailer/unknown_mailer_error_spec.rb create mode 100644 spec/unit/hanami/mailer_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index 9790ad7..474ebbb 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -2,3 +2,7 @@ # alphabetically inherit_from: - https://raw.githubusercontent.com/hanami/devtools/master/.rubocop.yml +Style/Documentation: + Exclude: + - "examples/*" + - "spec/**/*" diff --git a/Gemfile b/Gemfile index ce9d366..38326fe 100644 --- a/Gemfile +++ b/Gemfile @@ -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' diff --git a/README.md b/README.md index 1bbdafd..f5dabe2 100644 --- a/README.md +++ b/README.md @@ -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.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 + # + # + # + #

Invoice template

+ # + # + # + # ----==_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 # => # +configuration.root # => # # For InvoiceMailer, it looks for: # * app/templates/invoice_mailer.html.erb @@ -164,9 +202,7 @@ Hanami::Mailer.configuration.root # => # 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. diff --git a/benchmark.rb b/benchmark.rb new file mode 100644 index 0000000..d73445b --- /dev/null +++ b/benchmark.rb @@ -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 diff --git a/config/flay.yml b/config/flay.yml deleted file mode 100644 index d55b4e3..0000000 --- a/config/flay.yml +++ /dev/null @@ -1,3 +0,0 @@ ---- -threshold: 10 -total_score: 77 diff --git a/config/flog.yml b/config/flog.yml index e97b949..db27c00 100644 --- a/config/flog.yml +++ b/config/flog.yml @@ -1,2 +1,2 @@ --- -threshold: 20.2 +threshold: 18.4 diff --git a/config/reek.yml b/config/reek.yml index 28474d4..e673d62 100644 --- a/config/reek.yml +++ b/config/reek.yml @@ -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: [] diff --git a/config/rubocop.yml b/config/rubocop.yml deleted file mode 100644 index a0b0976..0000000 --- a/config/rubocop.yml +++ /dev/null @@ -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 diff --git a/examples/base.rb b/examples/base.rb new file mode 100644 index 0000000..baa485f --- /dev/null +++ b/examples/base.rb @@ -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) diff --git a/examples/base/invoice.html.erb b/examples/base/invoice.html.erb new file mode 100644 index 0000000..425171b --- /dev/null +++ b/examples/base/invoice.html.erb @@ -0,0 +1,8 @@ + + + Invoice + + +

Invoice #<%= invoice.number %>

+ + diff --git a/examples/base/invoice.txt.erb b/examples/base/invoice.txt.erb new file mode 100644 index 0000000..9aefc3f --- /dev/null +++ b/examples/base/invoice.txt.erb @@ -0,0 +1 @@ +Invoice #<%= invoice.number %> diff --git a/hanami-mailer.gemspec b/hanami-mailer.gemspec index c1316f3..c43b968 100644 --- a/hanami-mailer.gemspec +++ b/hanami-mailer.gemspec @@ -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 diff --git a/lib/hanami-mailer.rb b/lib/hanami-mailer.rb deleted file mode 100644 index e8132d3..0000000 --- a/lib/hanami-mailer.rb +++ /dev/null @@ -1 +0,0 @@ -require 'hanami/mailer' # rubocop:disable Naming/FileName diff --git a/lib/hanami/mailer.rb b/lib/hanami/mailer.rb index 1805d41..8fcab53 100644 --- a/lib/hanami/mailer.rb +++ b/lib/hanami/mailer.rb @@ -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 from or to. - # - # @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 delivery_method - # is set on :test + # 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 html and txt template, they are - # both delivered. - # - # In order to selectively deliver only one of the two templates, use - # Signup::Welcome.deliver(format: :txt) - # - # All the given locals, excepted the reserved ones (:format and - # charset), 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 diff --git a/lib/hanami/mailer/configuration.rb b/lib/hanami/mailer/configuration.rb index 890eb75..8633db6 100644 --- a/lib/hanami/mailer/configuration.rb +++ b/lib/hanami/mailer/configuration.rb @@ -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 # => # - # - # @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 # => # - 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 (:exim) - # * Sendmail (:sendmail) - # * SMTP (:smtp, for local installations) - # * SMTP Connection (:smtp_connection, - # via Net::SMTP - for remote installations) - # * Test (:test, 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 (:smtp). + # 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: # - # * initialize(options = {}) - # * deliver!(mail) + # * `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 diff --git a/lib/hanami/mailer/dsl.rb b/lib/hanami/mailer/dsl.rb index fd8e91f..ba09f54 100644 --- a/lib/hanami/mailer/dsl.rb +++ b/lib/hanami/mailer/dsl.rb @@ -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 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. - # - # 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 diff --git a/lib/hanami/mailer/finalizer.rb b/lib/hanami/mailer/finalizer.rb new file mode 100644 index 0000000..c257ab1 --- /dev/null +++ b/lib/hanami/mailer/finalizer.rb @@ -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] 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 diff --git a/lib/hanami/mailer/rendering/template_name.rb b/lib/hanami/mailer/rendering/template_name.rb deleted file mode 100644 index e7abb82..0000000 --- a/lib/hanami/mailer/rendering/template_name.rb +++ /dev/null @@ -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 diff --git a/lib/hanami/mailer/rendering/templates_finder.rb b/lib/hanami/mailer/rendering/templates_finder.rb deleted file mode 100644 index ef0ebcc..0000000 --- a/lib/hanami/mailer/rendering/templates_finder.rb +++ /dev/null @@ -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 - # # => [#] - 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 diff --git a/lib/hanami/mailer/template.rb b/lib/hanami/mailer/template.rb index f328a1a..e0857e0 100644 --- a/lib/hanami/mailer/template.rb +++ b/lib/hanami/mailer/template.rb @@ -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 diff --git a/lib/hanami/mailer/template_name.rb b/lib/hanami/mailer/template_name.rb new file mode 100644 index 0000000..ba47938 --- /dev/null +++ b/lib/hanami/mailer/template_name.rb @@ -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 diff --git a/lib/hanami/mailer/templates_finder.rb b/lib/hanami/mailer/templates_finder.rb new file mode 100644 index 0000000..d199a53 --- /dev/null +++ b/lib/hanami/mailer/templates_finder.rb @@ -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") + # # => [#] + 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 diff --git a/lib/hanami/mailer/version.rb b/lib/hanami/mailer/version.rb index 06b0719..0dc5806 100644 --- a/lib/hanami/mailer/version.rb +++ b/lib/hanami/mailer/version.rb @@ -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 diff --git a/spec/integration/hanami/mailer/delivery_spec.rb b/spec/integration/hanami/mailer/delivery_spec.rb new file mode 100644 index 0000000..e8603d1 --- /dev/null +++ b/spec/integration/hanami/mailer/delivery_spec.rb @@ -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(%(

Hello World!

)) + 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(%(

Hello World!

)) + 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(%(

Hello World!

)) + 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 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index f28edbc..57942ae 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -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! diff --git a/spec/support/context.rb b/spec/support/context.rb new file mode 100644 index 0000000..d4cd077 --- /dev/null +++ b/spec/support/context.rb @@ -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 diff --git a/spec/support/fixtures.rb b/spec/support/fixtures.rb index 0eb38f0..5971fda 100644 --- a/spec/support/fixtures.rb +++ b/spec/support/fixtures.rb @@ -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 diff --git a/spec/support/fixtures/templates/method_mailer.txt.erb b/spec/support/fixtures/templates/proc_mailer.txt.erb similarity index 100% rename from spec/support/fixtures/templates/method_mailer.txt.erb rename to spec/support/fixtures/templates/proc_mailer.txt.erb diff --git a/spec/support/fixtures/templates/welcome_mailer b/spec/support/fixtures/templates/welcome_mailer new file mode 100644 index 0000000..97be962 --- /dev/null +++ b/spec/support/fixtures/templates/welcome_mailer @@ -0,0 +1,2 @@ +Fixture used by +spec/unit/hanami/mailer/templates_finder_spec.rb diff --git a/spec/support/fixtures/templates/welcome_mailer.erb b/spec/support/fixtures/templates/welcome_mailer.erb new file mode 100644 index 0000000..97be962 --- /dev/null +++ b/spec/support/fixtures/templates/welcome_mailer.erb @@ -0,0 +1,2 @@ +Fixture used by +spec/unit/hanami/mailer/templates_finder_spec.rb diff --git a/spec/support/fixtures/templates/welcome_mailer.html b/spec/support/fixtures/templates/welcome_mailer.html new file mode 100644 index 0000000..97be962 --- /dev/null +++ b/spec/support/fixtures/templates/welcome_mailer.html @@ -0,0 +1,2 @@ +Fixture used by +spec/unit/hanami/mailer/templates_finder_spec.rb diff --git a/spec/unit/hanami/mailer/configuration_spec.rb b/spec/unit/hanami/mailer/configuration_spec.rb index 50e3f4f..0d5b903 100644 --- a/spec/unit/hanami/mailer/configuration_spec.rb +++ b/spec/unit/hanami/mailer/configuration_spec.rb @@ -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 diff --git a/spec/unit/hanami/mailer/delivery_spec.rb b/spec/unit/hanami/mailer/delivery_spec.rb deleted file mode 100644 index 82cbad4..0000000 --- a/spec/unit/hanami/mailer/delivery_spec.rb +++ /dev/null @@ -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(%(

Hello World!

)) - 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(%(

Hello World!

)) - 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(%(

Hello World!

)) - 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 diff --git a/spec/unit/hanami/mailer/dsl_spec.rb b/spec/unit/hanami/mailer/dsl_spec.rb index 1e0451c..469baa0 100644 --- a/spec/unit/hanami/mailer/dsl_spec.rb +++ b/spec/unit/hanami/mailer/dsl_spec.rb @@ -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 diff --git a/spec/unit/hanami/mailer/error_spec.rb b/spec/unit/hanami/mailer/error_spec.rb new file mode 100644 index 0000000..a8bad56 --- /dev/null +++ b/spec/unit/hanami/mailer/error_spec.rb @@ -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 diff --git a/spec/unit/hanami/mailer/finalizer_spec.rb b/spec/unit/hanami/mailer/finalizer_spec.rb new file mode 100644 index 0000000..84612e9 --- /dev/null +++ b/spec/unit/hanami/mailer/finalizer_spec.rb @@ -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 diff --git a/spec/unit/hanami/mailer/missing_delivery_data_error_spec.rb b/spec/unit/hanami/mailer/missing_delivery_data_error_spec.rb new file mode 100644 index 0000000..915fdd4 --- /dev/null +++ b/spec/unit/hanami/mailer/missing_delivery_data_error_spec.rb @@ -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 diff --git a/spec/unit/hanami/mailer/rendering_spec.rb b/spec/unit/hanami/mailer/rendering_spec.rb deleted file mode 100644 index 6fd3811..0000000 --- a/spec/unit/hanami/mailer/rendering_spec.rb +++ /dev/null @@ -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(%(

Invoice template

)) - 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(%(

\nLuca\n

\n)) - end - end - end -end diff --git a/spec/unit/hanami/mailer/template_name_spec.rb b/spec/unit/hanami/mailer/template_name_spec.rb new file mode 100644 index 0000000..bfcb2eb --- /dev/null +++ b/spec/unit/hanami/mailer/template_name_spec.rb @@ -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 diff --git a/spec/unit/hanami/mailer/template_spec.rb b/spec/unit/hanami/mailer/template_spec.rb new file mode 100644 index 0000000..67a011b --- /dev/null +++ b/spec/unit/hanami/mailer/template_spec.rb @@ -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 diff --git a/spec/unit/hanami/mailer/templates_finder_spec.rb b/spec/unit/hanami/mailer/templates_finder_spec.rb new file mode 100644 index 0000000..c4a1230 --- /dev/null +++ b/spec/unit/hanami/mailer/templates_finder_spec.rb @@ -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: + # + # `..` + # + # 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 diff --git a/spec/unit/hanami/mailer/unknown_mailer_error_spec.rb b/spec/unit/hanami/mailer/unknown_mailer_error_spec.rb new file mode 100644 index 0000000..f1f913a --- /dev/null +++ b/spec/unit/hanami/mailer/unknown_mailer_error_spec.rb @@ -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 diff --git a/spec/unit/hanami/mailer/version_spec.rb b/spec/unit/hanami/mailer/version_spec.rb index 636b839..d574f32 100644 --- a/spec/unit/hanami/mailer/version_spec.rb +++ b/spec/unit/hanami/mailer/version_spec.rb @@ -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") diff --git a/spec/unit/hanami/mailer_spec.rb b/spec/unit/hanami/mailer_spec.rb new file mode 100644 index 0000000..5fa7a25 --- /dev/null +++ b/spec/unit/hanami/mailer_spec.rb @@ -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(%(

Invoice template

)) + 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(%(

\n#{locals.fetch(:user).name}\n

\n)) + end + end + end +end