├── .jrubyrc ├── config ├── flog.yml ├── yardstick.yml ├── devtools.yml ├── mutant.yml └── reek.yml ├── .rspec ├── spec ├── support │ ├── fixtures │ │ └── templates │ │ │ ├── charset_mailer.txt.erb │ │ │ ├── missing.txt.erb │ │ │ ├── proc_mailer.txt.erb │ │ │ ├── lazy_mailer.txt.erb │ │ │ ├── render_mailer.html.erb │ │ │ ├── template_engine_mailer.html.haml │ │ │ ├── welcome_mailer.txt.erb │ │ │ ├── static_layout.txt.erb │ │ │ ├── welcome_mailer │ │ │ ├── welcome_mailer.erb │ │ │ ├── welcome_mailer.html │ │ │ ├── invoice.html.erb │ │ │ ├── lazy_mailer.html.erb │ │ │ ├── with_dynamic_layout_mailer.txt.erb │ │ │ ├── with_static_layout_mailer.txt.erb │ │ │ ├── with_dynamic_layout_mailer.html.erb │ │ │ ├── with_static_layout_mailer.html.erb │ │ │ ├── dynamic_layout.txt.erb │ │ │ ├── welcome_mailer.html.erb │ │ │ ├── static_layout.html.erb │ │ │ └── dynamic_layout.html.erb │ ├── context.rb │ ├── rspec.rb │ └── fixtures.rb ├── spec_helper.rb ├── unit │ └── hanami │ │ ├── mailer │ │ ├── error_spec.rb │ │ ├── version_spec.rb │ │ ├── missing_delivery_data_error_spec.rb │ │ ├── unknown_mailer_error_spec.rb │ │ ├── finalizer_spec.rb │ │ ├── template_spec.rb │ │ ├── template_name_spec.rb │ │ ├── templates_finder_spec.rb │ │ ├── configuration_spec.rb │ │ └── dsl_spec.rb │ │ └── mailer_spec.rb └── integration │ └── hanami │ └── mailer │ └── delivery_spec.rb ├── examples ├── base │ ├── invoice.txt.erb │ └── invoice.html.erb └── base.rb ├── bin ├── setup └── console ├── lib └── hanami │ ├── mailer │ ├── version.rb │ ├── template_name.rb │ ├── finalizer.rb │ ├── template.rb │ ├── templates_finder.rb │ ├── configuration.rb │ └── dsl.rb │ └── mailer.rb ├── .gitignore ├── .rubocop.yml ├── Gemfile ├── Rakefile ├── script └── ci ├── LICENSE.md ├── .github └── workflows │ └── ci.yml ├── benchmark.rb ├── hanami-mailer.gemspec ├── CHANGELOG.md └── README.md /.jrubyrc: -------------------------------------------------------------------------------- 1 | debug.fullTrace=true 2 | -------------------------------------------------------------------------------- /config/flog.yml: -------------------------------------------------------------------------------- 1 | --- 2 | threshold: 18.4 3 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /config/yardstick.yml: -------------------------------------------------------------------------------- 1 | --- 2 | threshold: 100 3 | -------------------------------------------------------------------------------- /config/devtools.yml: -------------------------------------------------------------------------------- 1 | --- 2 | unit_test_timeout: 0.2 3 | -------------------------------------------------------------------------------- /spec/support/fixtures/templates/charset_mailer.txt.erb: -------------------------------------------------------------------------------- 1 | 蓮 2 | -------------------------------------------------------------------------------- /spec/support/fixtures/templates/missing.txt.erb: -------------------------------------------------------------------------------- 1 | Missin' 2 | -------------------------------------------------------------------------------- /examples/base/invoice.txt.erb: -------------------------------------------------------------------------------- 1 | Invoice #<%= invoice.number %> 2 | -------------------------------------------------------------------------------- /spec/support/fixtures/templates/proc_mailer.txt.erb: -------------------------------------------------------------------------------- 1 | <%= greeting %> 2 | -------------------------------------------------------------------------------- /spec/support/fixtures/templates/lazy_mailer.txt.erb: -------------------------------------------------------------------------------- 1 | This is a txt template 2 | -------------------------------------------------------------------------------- /spec/support/fixtures/templates/render_mailer.html.erb: -------------------------------------------------------------------------------- 1 | Hello <%= user.name %>, 2 | -------------------------------------------------------------------------------- /spec/support/fixtures/templates/template_engine_mailer.html.haml: -------------------------------------------------------------------------------- 1 | %h1 2 | = user.name 3 | -------------------------------------------------------------------------------- /spec/support/fixtures/templates/welcome_mailer.txt.erb: -------------------------------------------------------------------------------- 1 | This is a txt template 2 | <%= greeting %> 3 | -------------------------------------------------------------------------------- /spec/support/fixtures/templates/static_layout.txt.erb: -------------------------------------------------------------------------------- 1 | MAIL-HEADER 2 | 3 | <%= yield %> 4 | 5 | MAIL-FOOTER 6 | -------------------------------------------------------------------------------- /spec/support/fixtures/templates/welcome_mailer: -------------------------------------------------------------------------------- 1 | Fixture used by 2 | spec/unit/hanami/mailer/templates_finder_spec.rb 3 | -------------------------------------------------------------------------------- /spec/support/fixtures/templates/welcome_mailer.erb: -------------------------------------------------------------------------------- 1 | Fixture used by 2 | spec/unit/hanami/mailer/templates_finder_spec.rb 3 | -------------------------------------------------------------------------------- /config/mutant.yml: -------------------------------------------------------------------------------- 1 | name: devtools 2 | namespace: "Hanami::Mailer" 3 | ignore_subjects: 4 | - "Devtools::Flay::Scale#flay" 5 | -------------------------------------------------------------------------------- /spec/support/fixtures/templates/welcome_mailer.html: -------------------------------------------------------------------------------- 1 | Fixture used by 2 | spec/unit/hanami/mailer/templates_finder_spec.rb 3 | -------------------------------------------------------------------------------- /spec/support/fixtures/templates/invoice.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Invoice template

4 | 5 | 6 | -------------------------------------------------------------------------------- /spec/support/fixtures/templates/lazy_mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Hello World!

4 | 5 | 6 | -------------------------------------------------------------------------------- /spec/support/fixtures/templates/with_dynamic_layout_mailer.txt.erb: -------------------------------------------------------------------------------- 1 | Mailer content only, the rest of the email is part of the layout -------------------------------------------------------------------------------- /spec/support/fixtures/templates/with_static_layout_mailer.txt.erb: -------------------------------------------------------------------------------- 1 | Mailer content only, the rest of the email is part of the layout -------------------------------------------------------------------------------- /spec/support/fixtures/templates/with_dynamic_layout_mailer.html.erb: -------------------------------------------------------------------------------- 1 |

Mailer content only, the rest of the email is part of the layout

-------------------------------------------------------------------------------- /spec/support/fixtures/templates/with_static_layout_mailer.html.erb: -------------------------------------------------------------------------------- 1 |

Mailer content only, the rest of the email is part of the layout

-------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /spec/support/fixtures/templates/dynamic_layout.txt.erb: -------------------------------------------------------------------------------- 1 | MAIL-HEADER-<%= layout_local %> 2 | 3 | <%= yield %> 4 | 5 | MAIL-FOOTER-<%= layout_local %> 6 | -------------------------------------------------------------------------------- /lib/hanami/mailer/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hanami 4 | class Mailer 5 | # @since 0.1.0 6 | VERSION = "2.0.0.alpha1" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /examples/base/invoice.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | Invoice 4 | 5 | 6 |

Invoice #<%= invoice.number %>

7 | 8 | 9 | -------------------------------------------------------------------------------- /spec/support/fixtures/templates/welcome_mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

Hello World!

5 | <%= greeting %> 6 |

This is a html template

7 | 8 | 9 | -------------------------------------------------------------------------------- /spec/support/fixtures/templates/static_layout.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

MAIL-HEADER

5 | 6 | <%= yield %> 7 | 8 |

MAIL-FOOTER

9 | 10 | 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | .devnotes 11 | .greenbar 12 | .rubocop-* 13 | measurements 14 | .byebug_history 15 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift "lib" 4 | require "hanami/utils" 5 | require "hanami/devtools/unit" 6 | require "hanami/mailer" 7 | 8 | Hanami::Utils.require!("spec/support") 9 | -------------------------------------------------------------------------------- /spec/unit/hanami/mailer/error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Hanami::Mailer::Error do 4 | it "inherits from StandardError" do 5 | expect(described_class.ancestors).to include(StandardError) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/unit/hanami/mailer/version_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe "Hanami::Mailer::VERSION" do 4 | it "returns current version" do 5 | expect(Hanami::Mailer::VERSION).to eq("2.0.0.alpha1") 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/fixtures/templates/dynamic_layout.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

MAIL-HEADER-<%= layout_local %>

5 | 6 | <%= yield %> 7 | 8 |

MAIL-FOOTER-<%= layout_local %>

9 | 10 | 11 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # Please keep AllCops, Bundler, Style, Metrics groups and then order cops 2 | # alphabetically 3 | inherit_from: 4 | - https://raw.githubusercontent.com/hanami/devtools/main/.rubocop.yml 5 | Style/Documentation: 6 | Exclude: 7 | - "examples/*" 8 | - "spec/**/*" 9 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "hanami/mailer" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start 16 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | gemspec 5 | 6 | unless ENV["CI"] 7 | gem "byebug", require: false, platforms: :mri 8 | gem "yard", require: false 9 | end 10 | 11 | gem "hanami-utils", "~> 2.0.alpha", require: false, git: "https://github.com/hanami/utils.git", branch: "main" 12 | gem "haml" 13 | 14 | gem "hanami-devtools", require: false, git: "https://github.com/hanami/devtools.git", branch: "main" 15 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rake" 4 | require "bundler/gem_tasks" 5 | require "rspec/core/rake_task" 6 | require "hanami/devtools/rake_tasks" 7 | 8 | namespace :spec do 9 | RSpec::Core::RakeTask.new(:unit) do |task| 10 | file_list = FileList["spec/**/*_spec.rb"] 11 | file_list = file_list.exclude("spec/{integration,isolation}/**/*_spec.rb") 12 | 13 | task.pattern = file_list 14 | end 15 | end 16 | 17 | task default: "spec:unit" 18 | -------------------------------------------------------------------------------- /spec/unit/hanami/mailer/missing_delivery_data_error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Hanami::Mailer::MissingDeliveryDataError do 4 | it "inherits from Hanami::Error" do 5 | expect(described_class.ancestors).to include(Hanami::Mailer::Error) 6 | end 7 | 8 | it "has a custom error message" do 9 | expect { raise described_class }.to raise_error(described_class, "Missing delivery data, please check 'from', or 'to'") 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /script/ci: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | prepare_build() { 6 | if [ -d coverage ]; then 7 | rm -rf coverage 8 | fi 9 | } 10 | 11 | print_ruby_version() { 12 | echo "Using $(ruby -v)" 13 | echo 14 | } 15 | 16 | run_code_quality_checks() { 17 | bundle exec rubocop . 18 | } 19 | 20 | run_unit_tests() { 21 | bundle exec rake spec:unit 22 | } 23 | 24 | upload_code_coverage() { 25 | bundle exec rake codecov:upload 26 | } 27 | 28 | main() { 29 | prepare_build 30 | print_ruby_version 31 | run_code_quality_checks 32 | run_unit_tests 33 | # upload_code_coverage 34 | } 35 | 36 | main 37 | -------------------------------------------------------------------------------- /lib/hanami/mailer/template_name.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "hanami/utils/string" 4 | 5 | module Hanami 6 | class Mailer 7 | # @since 0.1.0 8 | # @api private 9 | # 10 | # TODO this is identical to Hanami::View, consider to move into Hanami::Utils 11 | class TemplateName 12 | # @since next 13 | # @api unstable 14 | def self.call(name, namespace) 15 | Utils::String.underscore(name.gsub(/\A#{namespace}(::)*/, "")) 16 | end 17 | 18 | class << self 19 | # @since next 20 | # @api unstable 21 | alias_method :[], :call 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/support/context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RSpec 4 | module Support 5 | module Context 6 | def self.included(base) 7 | base.class_eval do 8 | let(:configuration) do 9 | configuration = Hanami::Mailer::Configuration.new do |config| 10 | config.root = "spec/support/fixtures" 11 | config.delivery_method = :test 12 | end 13 | 14 | Hanami::Mailer.finalize(configuration) 15 | end 16 | end 17 | end 18 | end 19 | end 20 | end 21 | 22 | RSpec.configure do |config| 23 | config.include(RSpec::Support::Context) 24 | end 25 | -------------------------------------------------------------------------------- /spec/support/rspec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.configure do |config| 4 | config.expect_with :rspec do |expectations| 5 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 6 | end 7 | 8 | config.mock_with :rspec do |mocks| 9 | mocks.verify_partial_doubles = true 10 | end 11 | 12 | config.shared_context_metadata_behavior = :apply_to_host_groups 13 | 14 | config.filter_run_when_matching :focus 15 | config.disable_monkey_patching! 16 | 17 | config.warnings = true 18 | 19 | config.default_formatter = "doc" if config.files_to_run.one? 20 | 21 | config.profile_examples = 10 22 | 23 | config.order = :random 24 | Kernel.srand config.seed 25 | end 26 | -------------------------------------------------------------------------------- /spec/unit/hanami/mailer/unknown_mailer_error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Hanami::Mailer::UnknownMailerError do 4 | it "inherits from Hanami::Error" do 5 | expect(described_class.ancestors).to include(Hanami::Mailer::Error) 6 | end 7 | 8 | it "has a custom error message" do 9 | mailer = InvoiceMailer 10 | expect { raise described_class.new(mailer) }.to raise_error(described_class, "Unknown mailer: #{mailer}. Please finalize the configuration before to use it.") 11 | end 12 | 13 | it "has explicit handling for nil" do 14 | mailer = nil 15 | expect { raise described_class.new(mailer) }.to raise_error(described_class, "Unknown mailer: #{mailer.inspect}. Please finalize the configuration before to use it.") 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/unit/hanami/mailer/finalizer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Hanami::Mailer::Finalizer do 4 | let(:configuration) do 5 | Hanami::Mailer::Configuration.new do |config| 6 | config.root = "spec/support/fixtures" 7 | end 8 | end 9 | 10 | let(:mailers) { [double("mailer", template_name: "invoice")] } 11 | 12 | describe ".finalize" do 13 | it "eager autoloads modules from mail gem" do 14 | expect(Mail).to receive(:eager_autoload!) 15 | described_class.finalize(mailers, configuration) 16 | end 17 | 18 | it "adds the mailer to the configuration" do 19 | expect(configuration).to receive(:add_mailer).with(mailers.first) 20 | described_class.finalize(mailers, configuration) 21 | end 22 | 23 | it "returns frozen configuration" do 24 | actual = described_class.finalize(mailers, configuration) 25 | expect(actual).to be_frozen 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/hanami/mailer/finalizer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "mail" 4 | # require 'ice_nine' 5 | 6 | module Hanami 7 | class Mailer 8 | # @since next 9 | # @api unstable 10 | class Finalizer 11 | # Finalize the given configuration before to start to use the mailers 12 | # 13 | # @param mailers [Array] all the subclasses of 14 | # `Hanami::Mailer` 15 | # @param configuration [Hanami::Mailer::Configuration] the configuration 16 | # to finalize 17 | # 18 | # @return configuration [Hanami::Mailer::Configuration] the finalized 19 | # configuration 20 | # 21 | # @since next 22 | # @api unstable 23 | def self.finalize(mailers, configuration) 24 | Mail.eager_autoload! 25 | mailers.each { |mailer| configuration.add_mailer(mailer) } 26 | 27 | configuration.freeze 28 | configuration 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/hanami/mailer/template.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "tilt" 4 | 5 | module Hanami 6 | class Mailer 7 | # A logic-less template. 8 | # 9 | # @api private 10 | # @since 0.1.0 11 | # 12 | # TODO this is identical to Hanami::View, consider to move into Hanami::Utils 13 | class Template 14 | def initialize(template, encoding = Encoding::UTF_8) 15 | @_template = Tilt.new(template, default_encoding: encoding) 16 | freeze 17 | end 18 | 19 | # Render the template within the context of the given scope. 20 | # 21 | # @param scope [Object] the rendering scope 22 | # @param locals [Hash] set of objects passed to the constructor 23 | # 24 | # @return [String] the output of the rendering process 25 | # 26 | # @api private 27 | # @since 0.1.0 28 | def render(scope, locals = {}) 29 | @_template.render(scope.dup, locals) 30 | end 31 | 32 | def render_layout(content, locals = {}) 33 | @_template.render(nil, locals) { content } 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © 2015-2021 Luca Guidi 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /examples/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require "hanami/mailer" 5 | 6 | configuration = Hanami::Mailer::Configuration.new do |config| 7 | config.root = File.expand_path(__dir__, "base") 8 | config.delivery_method = :test 9 | end 10 | 11 | class Invoice 12 | attr_reader :id, :number 13 | 14 | def initialize(id, number) 15 | @id = id 16 | @number = number 17 | freeze 18 | end 19 | end 20 | 21 | class User 22 | attr_reader :name, :email 23 | 24 | def initialize(name, email) 25 | @name = name 26 | @email = email 27 | freeze 28 | end 29 | end 30 | 31 | class InvoiceMailer < Hanami::Mailer 32 | template "invoice" 33 | 34 | from "invoices@domain.test" 35 | to ->(locals) { locals.fetch(:user).email } 36 | 37 | subject ->(locals) { "Invoice ##{locals.fetch(:invoice).number}" } 38 | end 39 | 40 | configuration = Hanami::Mailer.finalize(configuration) 41 | 42 | invoice = Invoice.new(1, 23) 43 | user = User.new("Luca", "luca@domain.test") 44 | 45 | mailer = InvoiceMailer.new(configuration: configuration) 46 | puts mailer.deliver(invoice: invoice, user: user) 47 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | "on": 4 | push: 5 | paths: 6 | - ".github/workflows/ci.yml" 7 | - "lib/**" 8 | - "*.gemspec" 9 | - "spec/**" 10 | - "Rakefile" 11 | - "Gemfile" 12 | - ".rubocop.yml" 13 | - "script/ci" 14 | pull_request: 15 | branches: 16 | - main 17 | schedule: 18 | - cron: "30 4 * * *" 19 | create: 20 | 21 | jobs: 22 | tests: 23 | runs-on: ubuntu-latest 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | ruby: 28 | - "3.1" 29 | - "3.0" 30 | steps: 31 | - uses: ravsamhq/notify-slack-action@v1 32 | if: always() 33 | with: 34 | status: ${{ job.status }} 35 | notify_when: "failure" 36 | env: 37 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} 38 | - uses: actions/checkout@v1 39 | - name: Install package dependencies 40 | run: "[ -e $APT_DEPS ] || sudo apt-get install -y --no-install-recommends $APT_DEPS" 41 | - name: Set up Ruby 42 | uses: ruby/setup-ruby@v1 43 | with: 44 | ruby-version: ${{matrix.ruby}} 45 | - name: Install latest bundler 46 | run: | 47 | gem install bundler --no-document 48 | - name: Bundle install 49 | run: bundle install --jobs 4 --retry 3 50 | - name: Run all tests 51 | run: script/ci 52 | -------------------------------------------------------------------------------- /spec/unit/hanami/mailer/template_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Hanami::Mailer::Template do 4 | subject { described_class.new(file) } 5 | let(:file) { "spec/support/fixtures/templates/welcome_mailer.txt.erb" } 6 | 7 | describe "#initialize" do 8 | context "with existing file" do 9 | it "instantiates template" do 10 | expect(subject).to be_kind_of(described_class) 11 | end 12 | 13 | it "initialize frozen instance" do 14 | expect(subject).to be_frozen 15 | end 16 | end 17 | 18 | context "with missing template engine" do 19 | it "returns error" do 20 | expect { described_class.new("Gemfile") }.to raise_error(RuntimeError, "No template engine registered for Gemfile") 21 | end 22 | end 23 | 24 | context "with unexisting file" do 25 | it "returns error" do 26 | expect { described_class.new("foo.erb") }.to raise_error(Errno::ENOENT) 27 | end 28 | end 29 | end 30 | 31 | describe "#render" do 32 | it "renders template" do 33 | scope = Object.new 34 | actual = subject.render(scope, greeting: "Hello") 35 | 36 | expect(actual).to eq("This is a txt template\nHello") 37 | end 38 | 39 | it "renders with unfrozen object" do 40 | scope = Object.new 41 | expect(scope).to receive(:dup) 42 | 43 | subject.render(scope, greeting: "Hello") 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /benchmark.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require "hanami/mailer" 5 | require "benchmark/ips" 6 | require "allocation_stats" 7 | require_relative "examples/base" 8 | 9 | configuration = Hanami::Mailer::Configuration.new do |config| 10 | config.root = "examples/base" 11 | config.delivery_method = :test 12 | end 13 | 14 | configuration = Hanami::Mailer.finalize(configuration) 15 | 16 | invoice = Invoice.new(1, 23) 17 | user = User.new("Luca", "luca@domain.test") 18 | 19 | mailer = InvoiceMailer.new(configuration: configuration) 20 | 21 | Benchmark.ips do |x| 22 | # # Configure the number of seconds used during 23 | # # the warmup phase (default 2) and calculation phase (default 5) 24 | # x.config(time: 5, warmup: 2) 25 | x.report "deliver" do 26 | mailer.deliver(invoice: invoice, user: user) 27 | end 28 | end 29 | 30 | stats = AllocationStats.new(burn: 5).trace do 31 | 1_000.times do 32 | mailer.deliver(invoice: invoice, user: user) 33 | end 34 | end 35 | 36 | total_allocations = stats.allocations.all.size 37 | puts "total allocations: #{total_allocations}" 38 | 39 | total_memsize = stats.allocations.bytes.to_a.inject(&:+) 40 | puts "total memsize: #{total_memsize}" 41 | 42 | detailed_allocations = stats.allocations(alias_paths: true) 43 | .group_by(:sourcefile, :class_plus) 44 | .sort_by_count 45 | .to_text 46 | 47 | puts "allocations by source file and class:" 48 | puts detailed_allocations 49 | -------------------------------------------------------------------------------- /hanami-mailer.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path("../lib", __FILE__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "hanami/mailer/version" 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "hanami-mailer" 9 | spec.version = Hanami::Mailer::VERSION 10 | spec.authors = ["Luca Guidi"] 11 | spec.email = ["me@lucaguidi.com"] 12 | 13 | spec.summary = "Mail for Ruby applications." 14 | spec.description = "Mail for Ruby applications and Hanami mailers" 15 | spec.homepage = "http://hanamirb.org" 16 | spec.license = "MIT" 17 | 18 | spec.files = `git ls-files -- lib/* CHANGELOG.md LICENSE.md README.md hanami-mailer.gemspec`.split($/) 19 | spec.bindir = "exe" 20 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 21 | spec.require_paths = ["lib"] 22 | spec.metadata["rubygems_mfa_required"] = "true" 23 | spec.required_ruby_version = ">= 3.0" 24 | 25 | spec.add_dependency "hanami-utils", "~> 2.0.alpha" 26 | spec.add_dependency "tilt", "~> 2.0", ">= 2.0.1" 27 | spec.add_dependency "mail", "~> 2.7" 28 | 29 | # FIXME: remove when https://github.com/mikel/mail/pull/1439 gets merged AND a new version of `mail` gets released 30 | spec.add_dependency "net-smtp", "~> 0.3" 31 | spec.add_dependency "net-pop", "~> 0.1" 32 | spec.add_dependency "net-imap", "~> 0.2" 33 | 34 | spec.add_development_dependency "bundler", ">= 1.6", "< 3" 35 | spec.add_development_dependency "rake", "~> 13" 36 | spec.add_development_dependency "rspec", "~> 3.9" 37 | spec.add_development_dependency "rubocop", "~> 1.0" 38 | end 39 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Hanami::Mailer 2 | Mail for Ruby applications 3 | 4 | ## v2.0.0.alpha1 (unreleased) 5 | ### Changed 6 | - [Luca Guidi] Drop support for Ruby: MRI 2.3, 2.4, 2.5, 2.6 7 | 8 | ## v1.3.3 - 2021-01-14 9 | ### Added 10 | - [Luca Guidi] Official support for Ruby: MRI 3.0 11 | 12 | ## v1.3.2 - 2020-02-03 13 | ### Added 14 | - [Luca Guidi] Official support for Ruby: MRI 2.7 15 | - [glaszig] Added `Hanami::Mailer.return_path` and `#return_path` to specify `MAIL FROM` address 16 | 17 | ## v1.3.1 - 2019-01-18 18 | ### Added 19 | - [Luca Guidi] Official support for Ruby: MRI 2.6 20 | - [Luca Guidi] Support `bundler` 2.0+ 21 | 22 | ## v1.3.0 - 2018-10-24 23 | ### Added 24 | - [Ben Bachhuber] Added support for `reply_to` 25 | 26 | ## v1.3.0.beta1 - 2018-08-08 27 | ### Added 28 | - [Luca Guidi] Official support for JRuby 9.2.0.0 29 | 30 | ## v1.2.0 - 2018-04-11 31 | 32 | ## v1.2.0.rc2 - 2018-04-06 33 | 34 | ## v1.2.0.rc1 - 2018-03-30 35 | 36 | ## v1.2.0.beta2 - 2018-03-23 37 | 38 | ## v1.2.0.beta1 - 2018-02-28 39 | ### Added 40 | - [Luca Guidi] Official support for Ruby: MRI 2.5 41 | 42 | ## v1.1.0 - 2017-10-25 43 | 44 | ## v1.1.0.rc1 - 2017-10-16 45 | 46 | ## v1.1.0.beta3 - 2017-10-04 47 | 48 | ## v1.1.0.beta2 - 2017-10-03 49 | 50 | ## v1.1.0.beta1 - 2017-08-11 51 | 52 | ## v1.0.0 - 2017-04-06 53 | 54 | ## v1.0.0.rc1 - 2017-03-31 55 | ### Fixed 56 | - [Luca Guidi] Let `Hanami::Mailer.deliver` to bubble up `ArgumentError` exceptions 57 | 58 | ## v1.0.0.beta2 - 2017-03-17 59 | 60 | ## v1.0.0.beta1 - 2017-02-14 61 | ### Added 62 | - [Luca Guidi] Official support for Ruby: MRI 2.4 63 | 64 | ## v0.4.0 - 2016-11-15 65 | ### Changed 66 | - [Luca Guidi] Official support for Ruby: MRI 2.3+ and JRuby 9.1.5.0+ 67 | 68 | ## v0.3.0 - 2016-07-22 69 | ### Added 70 | - [Anton Davydov] Blind carbon copy (bcc) option 71 | - [Anton Davydov] Carbon copy (cc) option 72 | 73 | ### Changed 74 | - [Luca Guidi] Drop support for Ruby 2.0 and 2.1 75 | 76 | ## v0.2.0 - 2016-01-22 77 | ### Changed 78 | - [Luca Guidi] Renamed the project 79 | 80 | ## v0.1.0 - 2015-09-30 81 | ### Added 82 | - [Ines Coelho & Rosa Faria & Luca Guidi] Email delivery 83 | - [Ines Coelho & Rosa Faria & Luca Guidi] Attachments 84 | - [Ines Coelho & Rosa Faria & Luca Guidi] Multipart rendering 85 | - [Ines Coelho & Rosa Faria & Luca Guidi] Configuration 86 | - [Ines Coelho & Rosa Faria & Luca Guidi] Official support for Ruby 2.0 87 | -------------------------------------------------------------------------------- /spec/unit/hanami/mailer/template_name_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Hanami::Mailer::TemplateName do 4 | describe ".call" do 5 | context "with top level namespace" do 6 | let(:namespace) { Object } 7 | 8 | it "returns an instance of ::String" do 9 | template_name = described_class.call("template", namespace) 10 | expect(template_name).to be_kind_of(String) 11 | end 12 | 13 | it "returns name from plain name" do 14 | template_name = described_class.call("template", namespace) 15 | expect(template_name).to eq("template") 16 | end 17 | 18 | it "returns name from camel case name" do 19 | template_name = described_class.call("ATemplate", namespace) 20 | expect(template_name).to eq("a_template") 21 | end 22 | 23 | it "returns name from snake case name" do 24 | template_name = described_class.call("a_template", namespace) 25 | expect(template_name).to eq("a_template") 26 | end 27 | 28 | it "returns name from modulized name" do 29 | template_name = described_class.call("Mailers::WelcomeMailer", namespace) 30 | expect(template_name).to eq("mailers/welcome_mailer") 31 | end 32 | 33 | it "returns name from class" do 34 | template_name = described_class.call(InvoiceMailer.name, namespace) 35 | expect(template_name).to eq("invoice_mailer") 36 | end 37 | 38 | it "returns name from modulized class" do 39 | template_name = described_class.call(Users::Welcome.name, namespace) 40 | expect(template_name).to eq("users/welcome") 41 | end 42 | 43 | it "returns blank string from blank name" do 44 | template_name = described_class.call("", namespace) 45 | expect(template_name).to eq("") 46 | end 47 | 48 | it "raises error with nil name" do 49 | expect { described_class.call(nil, namespace) }.to raise_error(NoMethodError) 50 | end 51 | end 52 | 53 | context "with application namespace" do 54 | let(:namespace) { Web::Mailers } 55 | 56 | it "returns name from class name" do 57 | template_name = described_class.call("SignupMailer", namespace) 58 | expect(template_name).to eq("signup_mailer") 59 | end 60 | 61 | it "returns name from modulized class name" do 62 | template_name = described_class.call(Web::Mailers::SignupMailer.name, namespace) 63 | expect(template_name).to eq("signup_mailer") 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/unit/hanami/mailer/templates_finder_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Hanami::Mailer::TemplatesFinder do 4 | subject { described_class.new(root) } 5 | # NOTE: please do not change this name, because `#find` specs are relying on 6 | # template fixtures. See all the fixtures under: spec/support/fixtures/templates 7 | let(:template_name) { "welcome_mailer" } 8 | let(:root) { configuration.root } 9 | 10 | describe "#initialize" do 11 | context "with valid root" do 12 | it "instantiates a new finder instance" do 13 | expect(subject).to be_kind_of(described_class) 14 | end 15 | 16 | it "returns a frozen object" do 17 | expect(subject).to be_frozen 18 | end 19 | end 20 | 21 | context "with unexisting root" do 22 | let(:root) { "path/to/unexisting" } 23 | 24 | it "raises error" do 25 | expect { subject }.to raise_error(Errno::ENOENT) 26 | end 27 | end 28 | 29 | context "with nil root" do 30 | let(:root) { nil } 31 | 32 | it "raises error" do 33 | expect { subject }.to raise_error(TypeError) 34 | end 35 | end 36 | end 37 | 38 | describe "#find" do 39 | context "with valid template name" do 40 | it "returns templates" do 41 | actual = subject.find(template_name) 42 | 43 | # It excludes all the files that aren't matching the convention: 44 | # 45 | # `..` 46 | # 47 | # Under `spec/support/fixtures/templates` we have the following files: 48 | # 49 | # * welcome_mailer 50 | # * welcome_mailer.erb 51 | # * welcome_mailer.html 52 | # * welcome_mailer.html.erb 53 | # * welcome_mailer.txt.erb 54 | # 55 | # Only the last two are matching the pattern, here's why we have only 56 | # two templates loaded. 57 | expect(actual.keys).to eq(%i[html txt]) 58 | actual.each_value do |template| 59 | expect(template).to be_kind_of(Hanami::Mailer::Template) 60 | expect(template.instance_variable_get(:@_template).__send__(:file)).to match(%r{spec/support/fixtures/templates/welcome_mailer.(html|txt).erb}) 61 | end 62 | end 63 | end 64 | 65 | context "with missing template" do 66 | let(:template_name) { "missing_template" } 67 | 68 | it "doesn't return templates" do 69 | actual = subject.find(template_name) 70 | 71 | expect(actual).to be_empty 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /config/reek.yml: -------------------------------------------------------------------------------- 1 | --- 2 | Attribute: 3 | enabled: false 4 | exclude: [] 5 | BooleanParameter: 6 | enabled: true 7 | exclude: [] 8 | ClassVariable: 9 | enabled: true 10 | exclude: [] 11 | ControlParameter: 12 | enabled: true 13 | exclude: [] 14 | DataClump: 15 | enabled: true 16 | max_copies: 0 17 | min_clump_size: 2 18 | exclude: 19 | - Hanami::Mailer 20 | DuplicateMethodCall: 21 | enabled: true 22 | exclude: [] 23 | max_calls: 1 24 | allow_calls: [] 25 | FeatureEnvy: 26 | enabled: true 27 | exclude: 28 | - Hanami::Mailer#__part? 29 | LongParameterList: 30 | enabled: true 31 | exclude: 32 | - Devtools::Config#self.attribute 33 | max_params: 4 34 | overrides: {} 35 | LongYieldList: 36 | enabled: true 37 | exclude: [] 38 | max_params: 1 39 | NestedIterators: 40 | enabled: true 41 | exclude: [] 42 | max_allowed_nesting: 1 43 | ignore_iterators: [] 44 | NilCheck: 45 | enabled: true 46 | exclude: 47 | - Hanami::Mailer::Dsl#bcc 48 | - Hanami::Mailer::Dsl#cc 49 | - Hanami::Mailer::Dsl#from 50 | - Hanami::Mailer::Dsl#subject 51 | - Hanami::Mailer::Dsl#to 52 | - Hanami::Mailer#__part? 53 | RepeatedConditional: 54 | enabled: true 55 | max_ifs: 1 56 | exclude: [] 57 | TooManyConstants: 58 | enabled: true 59 | exclude: 60 | - Devtools 61 | TooManyInstanceVariables: 62 | enabled: true 63 | max_instance_variables: 3 64 | exclude: 65 | - Hanami::Mailer::Configuration 66 | TooManyMethods: 67 | enabled: true 68 | exclude: [] 69 | max_methods: 15 70 | TooManyStatements: 71 | enabled: true 72 | max_statements: 5 73 | exclude: 74 | - Hanami::Mailer#bind 75 | - Hanami::Mailer::Configuration#initialize 76 | - Hanami::Mailer::Dsl#self.extended 77 | - Hanami::Mailer::TemplatesFinder#find 78 | UncommunicativeMethodName: 79 | enabled: true 80 | reject: 81 | - !ruby/regexp /^[a-z]$/ 82 | - !ruby/regexp /[0-9]$/ 83 | - !ruby/regexp /[A-Z]/ 84 | accept: [] 85 | exclude: [] 86 | UncommunicativeModuleName: 87 | enabled: true 88 | exclude: [] 89 | reject: 90 | - !ruby/regexp /^.$/ 91 | - !ruby/regexp /[0-9]$/ 92 | accept: [] 93 | UncommunicativeParameterName: 94 | enabled: true 95 | reject: 96 | - !ruby/regexp /^.$/ 97 | - !ruby/regexp /[0-9]$/ 98 | - !ruby/regexp /[A-Z]/ 99 | accept: [] 100 | exclude: [] 101 | UncommunicativeVariableName: 102 | enabled: true 103 | reject: 104 | - !ruby/regexp /^.$/ 105 | - !ruby/regexp /[0-9]$/ 106 | - !ruby/regexp /[A-Z]/ 107 | accept: [] 108 | exclude: [] 109 | UnusedParameters: 110 | enabled: true 111 | exclude: [] 112 | UtilityFunction: 113 | enabled: true 114 | exclude: 115 | - Devtools::Project::Initializer::Rspec#require_files # intentional for deduplication 116 | max_helper_calls: 0 117 | PrimaDonnaMethod: 118 | exclude: [] 119 | ModuleInitialize: 120 | exclude: [] 121 | InstanceVariableAssumption: 122 | exclude: [] 123 | -------------------------------------------------------------------------------- /spec/support/fixtures.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class InvoiceMailer < Hanami::Mailer 4 | template "invoice" 5 | end 6 | 7 | class RenderMailer < Hanami::Mailer 8 | end 9 | 10 | class TemplateEngineMailer < Hanami::Mailer 11 | end 12 | 13 | class CharsetMailer < Hanami::Mailer 14 | from "noreply@example.com" 15 | to "user@example.com" 16 | subject "こんにちは" 17 | end 18 | 19 | class MissingFromMailer < Hanami::Mailer 20 | template "missing" 21 | 22 | to "recipient@example.com" 23 | subject "Hello" 24 | end 25 | 26 | class MissingToMailer < Hanami::Mailer 27 | template "missing" 28 | 29 | from "sender@example.com" 30 | subject "Hello" 31 | end 32 | 33 | class CcOnlyMailer < Hanami::Mailer 34 | template "missing" 35 | 36 | cc "recipient@example.com" 37 | from "sender@example.com" 38 | subject "Hello" 39 | end 40 | 41 | class BccOnlyMailer < Hanami::Mailer 42 | template "missing" 43 | 44 | bcc "recipient@example.com" 45 | from "sender@example.com" 46 | subject "Hello" 47 | end 48 | 49 | User = Struct.new(:name, :email) 50 | 51 | class LazyMailer < Hanami::Mailer 52 | end 53 | 54 | class ProcMailer < Hanami::Mailer 55 | from ->(locals) { "hello-#{locals.fetch(:user).name.downcase}@example.com" } 56 | to ->(locals) { locals.fetch(:user).email } 57 | subject ->(locals) { "[Hanami] #{locals.fetch(:greeting)}" } 58 | 59 | before do |_, locals| 60 | locals[:greeting] = "Hello, #{locals.fetch(:user).name}" 61 | end 62 | end 63 | 64 | class WelcomeMailer < Hanami::Mailer 65 | from "noreply@sender.com" 66 | to ["noreply@recipient.com", "owner@recipient.com"] 67 | cc "cc@recipient.com" 68 | bcc "bcc@recipient.com" 69 | reply_to "reply_to@recipient.com" 70 | return_path "bounce@sender.com" 71 | 72 | subject "Welcome" 73 | 74 | before do |mail| 75 | mail.attachments["invoice.pdf"] = "/path/to/invoice-#{invoice_code}.pdf" 76 | end 77 | 78 | def greeting 79 | "Ahoy" 80 | end 81 | 82 | def invoice_code 83 | "123" 84 | end 85 | end 86 | 87 | class WithStaticLayoutMailer < Hanami::Mailer 88 | from "noreply@sender.com" 89 | to "owner@recipient.com" 90 | 91 | subject "Mail with static layout" 92 | 93 | layout "static_layout" 94 | end 95 | 96 | class WithDynamicLayoutMailer < Hanami::Mailer 97 | from "noreply@sender.com" 98 | to "owner@recipient.com" 99 | 100 | subject "Mail with dynamic layout" 101 | 102 | layout "dynamic_layout" 103 | end 104 | 105 | class EventMailer < Hanami::Mailer 106 | from "events@domain.test" 107 | to ->(locals) { locals.fetch(:user).email } 108 | subject ->(locals) { "Invitation: #{locals.fetch(:event).title}" } 109 | 110 | before do |mail, locals| 111 | mail.attachments["invitation-#{locals.fetch(:event).id}.ics"] = generate_invitation_attachment(locals) 112 | end 113 | 114 | private 115 | 116 | # Simulate on-the-fly creation of an attachment file. 117 | # For speed purposes we're not gonna create the file, but only return a path. 118 | def generate_invitation_attachment(locals) 119 | "invitation-#{locals.fetch(:event).id}.ics" 120 | end 121 | end 122 | 123 | class MandrillDeliveryMethod 124 | def initialize(options) 125 | @options = options 126 | end 127 | 128 | def deliver!(mail) 129 | @options.fetch(:deliveries).push(mail) 130 | end 131 | end 132 | 133 | module Users 134 | class Welcome < Hanami::Mailer 135 | end 136 | end 137 | 138 | module Web 139 | module Mailers 140 | class SignupMailer < Hanami::Mailer 141 | end 142 | end 143 | end 144 | 145 | module DefaultSubject 146 | def self.included(mailer) 147 | mailer.subject "default subject" 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /spec/unit/hanami/mailer/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Hanami::Mailer::Configuration do 4 | subject { described_class.new } 5 | 6 | describe "#root=" do 7 | describe "when a value is given" do 8 | describe "and it is a string" do 9 | it "sets it as a Pathname" do 10 | subject.root = "spec" 11 | expect(subject.root).to eq(Pathname.new("spec").realpath) 12 | end 13 | end 14 | 15 | describe "and it is a pathname" do 16 | it "sets it" do 17 | subject.root = Pathname.new("spec") 18 | expect(subject.root).to eq(Pathname.new("spec").realpath) 19 | end 20 | end 21 | 22 | describe "and it implements #to_pathname" do 23 | before do 24 | RootPath = Struct.new(:path) do 25 | def to_pathname 26 | Pathname(path) 27 | end 28 | end 29 | end 30 | 31 | after do 32 | Object.send(:remove_const, :RootPath) 33 | end 34 | 35 | it "sets the converted value" do 36 | subject.root = RootPath.new("spec") 37 | expect(subject.root).to eq(Pathname.new("spec").realpath) 38 | end 39 | end 40 | 41 | describe "and it is an unexisting path" do 42 | it "raises an error" do 43 | expect do 44 | subject.root = "/path/to/unknown" 45 | end.to raise_error(Errno::ENOENT) 46 | end 47 | end 48 | end 49 | 50 | describe "when a value is not given" do 51 | it "defaults to the current path" do 52 | expect(subject.root).to eq(Pathname.new(".").realpath) 53 | end 54 | end 55 | end 56 | 57 | describe "#delivery_method" do 58 | describe "when not previously set" do 59 | it "defaults to SMTP" do 60 | expect(subject.delivery_method).to eq(:smtp) 61 | end 62 | end 63 | 64 | describe "set with a symbol" do 65 | before do 66 | subject.delivery_method = :exim, {location: "/path/to/exim"} 67 | end 68 | 69 | it "saves the delivery method in the configuration" do 70 | expect(subject.delivery_method).to eq([:exim, {location: "/path/to/exim"}]) 71 | end 72 | end 73 | 74 | describe "set with a class" do 75 | before do 76 | subject.delivery_method = MandrillDeliveryMethod, 77 | {username: "mandrill-username", password: "mandrill-api-key"} 78 | end 79 | 80 | it "saves the delivery method in the configuration" do 81 | expect(subject.delivery_method).to eq([MandrillDeliveryMethod, {username: "mandrill-username", password: "mandrill-api-key"}]) 82 | end 83 | end 84 | end 85 | 86 | describe "#default_charset" do 87 | describe "when not previously set" do 88 | it "defaults to UTF-8" do 89 | expect(subject.default_charset).to eq("UTF-8") 90 | end 91 | end 92 | 93 | describe "when set" do 94 | before do 95 | subject.default_charset = "iso-8859-1" 96 | end 97 | 98 | it "saves the delivery method in the configuration" do 99 | expect(subject.default_charset).to eq("iso-8859-1") 100 | end 101 | end 102 | end 103 | 104 | describe "#with" do 105 | it "initialize a new instance with the given settings" do 106 | updated = subject.with do |config| 107 | config.delivery_method = :new 108 | end 109 | 110 | expect(updated.object_id).to_not be(subject.object_id) 111 | expect(updated.frozen?).to be(true) 112 | 113 | expect(subject.delivery_method).to_not eq(:new) 114 | expect(updated.delivery_method).to eq(:new) 115 | end 116 | 117 | it "raises error if no block is given" do 118 | expect { subject.with }.to raise_error(LocalJumpError) 119 | end 120 | end 121 | 122 | describe "#freeze" do 123 | before do 124 | subject.freeze 125 | end 126 | 127 | it "is frozen" do 128 | expect(subject).to be_frozen 129 | end 130 | 131 | it "raises error if trying to add a mailer" do 132 | expect { subject.add_mailer(WelcomeMailer) }.to raise_error(RuntimeError) 133 | end 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /lib/hanami/mailer/templates_finder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "hanami/mailer/template" 4 | require "hanami/utils/file_list" 5 | require "pathname" 6 | 7 | module Hanami 8 | class Mailer 9 | # Find templates for a mailer 10 | # 11 | # @api private 12 | # @since 0.1.0 13 | # 14 | # @see Mailer::Template 15 | class TemplatesFinder 16 | # Default format 17 | # 18 | # @api private 19 | # @since 0.1.0 20 | FORMAT = "*" 21 | 22 | # Default template engines 23 | # 24 | # @api private 25 | # @since 0.1.0 26 | ENGINES = "*" 27 | 28 | # Recursive pattern 29 | # 30 | # @api private 31 | # @since 0.1.0 32 | RECURSIVE = "**" 33 | 34 | # Format separator 35 | # 36 | # @api unstable 37 | # @since next 38 | # 39 | # @example 40 | # welcome.html.erb 41 | FORMAT_SEPARATOR = "." 42 | 43 | private_constant(*constants(true)) 44 | 45 | # Initialize a finder 46 | # 47 | # @param root [String,Pathname] the root directory where to recursively 48 | # look for templates 49 | # 50 | # @raise [Errno::ENOENT] if the directory doesn't exist 51 | # 52 | # @api unstable 53 | # @since 0.1.0 54 | def initialize(root) 55 | @root = Pathname.new(root).realpath 56 | freeze 57 | end 58 | 59 | # Find all the associated templates to the mailer. 60 | # 61 | # It starts under the root path and it **recursively** looks for templates 62 | # that are matching the given template name. 63 | # 64 | # @param template_name [String] the template name 65 | # 66 | # @return [Hash] the templates 67 | # 68 | # @api unstable 69 | # @since 0.1.0 70 | # 71 | # @example 72 | # require 'hanami/mailer' 73 | # 74 | # module Mailers 75 | # class Welcome < Hanami::Mailer 76 | # end 77 | # end 78 | # 79 | # configuration = Hanami::Mailer::Configuration.new do |config| 80 | # config.root = "path/to/templates" 81 | # end 82 | # 83 | # # This mailer has a template: 84 | # # 85 | # # "path/to/templates/welcome.html.erb" 86 | # 87 | # Hanami::Mailer::Rendering::TemplatesFinder.new(root).find("welcome") 88 | # # => [#] 89 | def find(template_name) 90 | templates(template_name).each_with_object({}) do |template, result| 91 | format = extract_format(template) 92 | result[format] = Mailer::Template.new(template) 93 | end 94 | end 95 | 96 | protected 97 | 98 | # @api unstable 99 | # @since 0.1.0 100 | def templates(template_name, lookup = search_path) 101 | root_path = [root, lookup, template_name].join(separator) 102 | search_path = "#{format_separator}#{format}#{format_separator}#{engines}" 103 | 104 | Utils::FileList["#{root_path}#{search_path}"] 105 | end 106 | 107 | # @api unstable 108 | # @since 0.1.0 109 | attr_reader :root 110 | 111 | # @api private 112 | # @since 0.1.0 113 | def search_path 114 | recursive 115 | end 116 | 117 | # @api private 118 | # @since 0.1.0 119 | def recursive 120 | RECURSIVE 121 | end 122 | 123 | # @api private 124 | # @since 0.1.0 125 | def separator 126 | ::File::SEPARATOR 127 | end 128 | 129 | # @api private 130 | # @since 0.1.0 131 | def format 132 | FORMAT 133 | end 134 | 135 | # @api private 136 | # @since 0.1.0 137 | def engines 138 | ENGINES 139 | end 140 | 141 | # @api unstable 142 | # @since next 143 | def format_separator 144 | FORMAT_SEPARATOR 145 | end 146 | 147 | # @api unstable 148 | # @since next 149 | def extract_format(template) 150 | filename = File.basename(template) 151 | filename.split(format_separator)[-2].to_sym 152 | end 153 | end 154 | end 155 | end 156 | -------------------------------------------------------------------------------- /spec/unit/hanami/mailer/dsl_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Hanami::Mailer::Dsl do 4 | let(:mailer) { Class.new { extend Hanami::Mailer::Dsl } } 5 | 6 | describe ".from" do 7 | it "returns the default value" do 8 | expect(mailer.from).to be(nil) 9 | end 10 | 11 | it "sets the value" do 12 | sender = "sender@hanami.test" 13 | mailer.from sender 14 | 15 | expect(mailer.from).to eq(sender) 16 | end 17 | end 18 | 19 | describe ".to" do 20 | it "returns the default value" do 21 | expect(mailer.to).to be(nil) 22 | end 23 | 24 | it "sets a single value" do 25 | recipient = "recipient@hanami.test" 26 | mailer.to recipient 27 | 28 | expect(mailer.to).to eq(recipient) 29 | end 30 | 31 | it "sets an array of values" do 32 | recipients = ["recipient@hanami.test"] 33 | mailer.to recipients 34 | 35 | expect(mailer.to).to eq(recipients) 36 | end 37 | end 38 | 39 | describe ".cc" do 40 | it "returns the default value" do 41 | expect(mailer.cc).to be(nil) 42 | end 43 | 44 | it "sets a single value" do 45 | recipient = "cc@hanami.test" 46 | mailer.cc recipient 47 | 48 | expect(mailer.cc).to eq(recipient) 49 | end 50 | 51 | it "sets an array of values" do 52 | recipients = ["cc@hanami.test"] 53 | mailer.cc recipients 54 | 55 | expect(mailer.cc).to eq(recipients) 56 | end 57 | end 58 | 59 | describe ".bcc" do 60 | it "returns the default value" do 61 | expect(mailer.bcc).to be(nil) 62 | end 63 | 64 | it "sets a single value" do 65 | recipient = "bcc@hanami.test" 66 | mailer.bcc recipient 67 | 68 | expect(mailer.bcc).to eq(recipient) 69 | end 70 | 71 | it "sets an array of values" do 72 | recipients = ["bcc@hanami.test"] 73 | mailer.bcc recipients 74 | 75 | expect(mailer.bcc).to eq(recipients) 76 | end 77 | end 78 | 79 | describe ".reply_to" do 80 | it "returns the default value" do 81 | expect(mailer.reply_to).to be(nil) 82 | end 83 | 84 | it "sets a single value" do 85 | email_address = "reply@hanami.test" 86 | mailer.reply_to email_address 87 | 88 | expect(mailer.reply_to).to eq(email_address) 89 | end 90 | 91 | it "sets an array of values" do 92 | email_addresses = ["bcc@hanami.test"] 93 | mailer.reply_to email_addresses 94 | 95 | expect(mailer.reply_to).to eq(email_addresses) 96 | end 97 | end 98 | 99 | describe ".return_path" do 100 | it "returns the default value" do 101 | expect(mailer.return_path).to be(nil) 102 | end 103 | 104 | it "sets a single value" do 105 | email_address = "return@hanami.test" 106 | mailer.return_path email_address 107 | 108 | expect(mailer.return_path).to eq(email_address) 109 | end 110 | 111 | it "sets an array of values" do 112 | email_addresses = ["return@hanami.test"] 113 | mailer.return_path email_addresses 114 | 115 | expect(mailer.return_path).to eq(email_addresses) 116 | end 117 | end 118 | 119 | describe ".subject" do 120 | it "returns the default value" do 121 | expect(mailer.subject).to be(nil) 122 | end 123 | 124 | it "sets a value" do 125 | mail_subject = "Hello" 126 | mailer.subject mail_subject 127 | 128 | expect(mailer.subject).to eq(mail_subject) 129 | end 130 | end 131 | 132 | describe ".template" do 133 | it "sets a value" do 134 | mailer.template "file" 135 | end 136 | end 137 | 138 | describe ".layout" do 139 | it "sets a value" do 140 | mailer.layout "file" 141 | end 142 | end 143 | 144 | describe ".template_name" do 145 | it "returns the default value" do 146 | expect(mailer.template_name).to be(nil) 147 | end 148 | 149 | it "returns value, if set" do 150 | template = "file" 151 | mailer.template template 152 | 153 | expect(mailer.template_name).to eq(template) 154 | end 155 | end 156 | 157 | describe ".before" do 158 | it "returns the default value" do 159 | expect(mailer.before).to be_kind_of(Proc) 160 | end 161 | 162 | it "sets a value" do 163 | blk = ->(*) {} 164 | mailer.before(&blk) 165 | 166 | expect(mailer.before).to eq(blk) 167 | end 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /spec/integration/hanami/mailer/delivery_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Hanami::Mailer do 4 | describe ".deliver" do 5 | it "can deliver with specified charset" do 6 | mail = CharsetMailer.new(configuration: configuration).deliver(charset: charset = "iso-2022-jp") 7 | 8 | expect(mail.charset).to eq(charset) 9 | expect(mail.parts.first.charset).to eq(charset) 10 | end 11 | 12 | it "raises error when 'from' isn't specified" do 13 | expect { MissingFromMailer.new(configuration: configuration).deliver({}) }.to raise_error(Hanami::Mailer::MissingDeliveryDataError) 14 | end 15 | 16 | it "raises error when 'to' isn't specified" do 17 | expect { MissingToMailer.new(configuration: configuration).deliver({}) }.to raise_error(Hanami::Mailer::MissingDeliveryDataError) 18 | end 19 | 20 | describe "test delivery with hardcoded values" do 21 | subject { WelcomeMailer.new(configuration: configuration).deliver({}) } 22 | 23 | it "sends the correct information" do 24 | expect(subject.from).to eq(["noreply@sender.com"]) 25 | expect(subject.to).to eq(["noreply@recipient.com", "owner@recipient.com"]) 26 | expect(subject.cc).to eq(["cc@recipient.com"]) 27 | expect(subject.bcc).to eq(["bcc@recipient.com"]) 28 | expect(subject.subject).to eq("Welcome") 29 | end 30 | 31 | it "has the correct templates" do 32 | expect(subject.html_part.to_s).to include(%(template)) 33 | expect(subject.text_part.to_s).to include(%(template)) 34 | end 35 | 36 | it "interprets the prepare statement" do 37 | attachment = subject.attachments["invoice.pdf"] 38 | 39 | expect(attachment).to be_kind_of(Mail::Part) 40 | 41 | expect(attachment).to be_attachment 42 | expect(attachment).to_not be_inline 43 | expect(attachment).to_not be_multipart 44 | 45 | expect(attachment.filename).to eq("invoice.pdf") 46 | 47 | expect(attachment.content_type).to match("application/pdf") 48 | expect(attachment.content_type).to match("filename=invoice.pdf") 49 | end 50 | end 51 | 52 | describe "test delivery with procs" do 53 | subject { ProcMailer.new(configuration: configuration).deliver(user: user) } 54 | let(:user) { User.new("Name", "student@deigirls.com") } 55 | 56 | it "sends the correct information" do 57 | expect(subject.from).to eq(["hello-#{user.name.downcase}@example.com"]) 58 | expect(subject.to).to eq([user.email]) 59 | expect(subject.subject).to eq("[Hanami] Hello, #{user.name}") 60 | end 61 | end 62 | 63 | describe "test delivery with locals" do 64 | subject { EventMailer.new(configuration: configuration) } 65 | let(:count) { 100 } 66 | 67 | it "delivers the message" do 68 | threads = [] 69 | mails = {} 70 | 71 | count.times do |i| 72 | threads << Thread.new do 73 | user = double(name: "Luca #{i}", email: "luca-#{i}@domain.test") 74 | event = double(id: i, title: "Event ##{i}") 75 | 76 | mails[i] = subject.deliver(user: user, event: event) 77 | end 78 | end 79 | threads.map(&:join) 80 | 81 | expect(mails.count).to eq(count) 82 | mails.each do |i, mail| 83 | expect(mail.to).to eq(["luca-#{i}@domain.test"]) 84 | expect(mail.subject).to eq("Invitation: Event ##{i}") 85 | expect(mail.attachments[0].filename).to eq("invitation-#{i}.ics") 86 | end 87 | end 88 | end 89 | 90 | describe "multipart" do 91 | it "delivers all the parts by default" do 92 | mail = WelcomeMailer.new(configuration: configuration).deliver({}) 93 | body = mail.body.encoded 94 | 95 | expect(body).to include(%(

Hello World!

)) 96 | expect(body).to include(%(This is a txt template)) 97 | end 98 | 99 | it "can deliver only the text part" do 100 | mail = WelcomeMailer.new(configuration: configuration).deliver(format: :txt) 101 | body = mail.body.encoded 102 | 103 | expect(body).to_not include(%(

Hello World!

)) 104 | expect(body).to include(%(This is a txt template)) 105 | end 106 | 107 | it "can deliver only the html part" do 108 | mail = WelcomeMailer.new(configuration: configuration).deliver(format: :html) 109 | body = mail.body.encoded 110 | 111 | expect(body).to include(%(

Hello World!

)) 112 | expect(body).to_not include(%(This is a txt template)) 113 | end 114 | end 115 | 116 | describe "custom delivery" do 117 | before do 118 | mailer.deliver({}) 119 | end 120 | 121 | subject { options.fetch(:deliveries).first } 122 | let(:mailer) { WelcomeMailer.new(configuration: configuration) } 123 | let(:options) { {deliveries: []} } 124 | 125 | let(:configuration) do 126 | configuration = Hanami::Mailer::Configuration.new do |config| 127 | config.root = "spec/support/fixtures" 128 | config.delivery_method = MandrillDeliveryMethod, options 129 | end 130 | 131 | Hanami::Mailer.finalize(configuration) 132 | end 133 | 134 | it "delivers the mail" do 135 | expect(options.fetch(:deliveries).size).to be(1) 136 | end 137 | 138 | it "sends the correct information" do 139 | expect(subject.from).to eq(["noreply@sender.com"]) 140 | expect(subject.to).to eq(["noreply@recipient.com", "owner@recipient.com"]) 141 | expect(subject.subject).to eq("Welcome") 142 | end 143 | 144 | it "has the correct templates" do 145 | expect(subject.html_part.to_s).to include(%(template)) 146 | expect(subject.text_part.to_s).to include(%(template)) 147 | end 148 | 149 | it "runs the before callback" do 150 | expect(subject.attachments["invoice.pdf"]).to_not be(nil) 151 | end 152 | end 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /spec/unit/hanami/mailer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ostruct" 4 | 5 | RSpec.describe Hanami::Mailer do 6 | context "constants" do 7 | it "marks them as private" do 8 | expect { described_class::CONTENT_TYPES }.to raise_error(NameError) 9 | end 10 | end 11 | 12 | context ".finalize" do 13 | let(:configuration) { Hanami::Mailer::Configuration.new } 14 | 15 | it "finalizes the given configuration" do 16 | actual = described_class.finalize(configuration) 17 | expect(actual).to be_frozen 18 | end 19 | end 20 | 21 | context "#initialize" do 22 | let(:configuration) { Hanami::Mailer::Configuration.new } 23 | 24 | it "builds an frozen instance" do 25 | mailer = described_class.new(configuration: configuration) 26 | expect(mailer).to be_frozen 27 | end 28 | 29 | it "prevents memoization mutations" do 30 | mailer = Class.new(described_class) do 31 | def self.name 32 | "memoization_attempt" 33 | end 34 | 35 | def foo 36 | @foo ||= "foo" 37 | end 38 | end.new(configuration: configuration) 39 | 40 | expect { mailer.foo }.to raise_error(RuntimeError) 41 | end 42 | 43 | it "prevents accidental configuration removal" do 44 | mailer = Class.new(described_class) do 45 | def self.name 46 | "configuration_removal" 47 | end 48 | 49 | def foo 50 | @configuration = nil 51 | end 52 | end.new(configuration: configuration) 53 | 54 | expect { mailer.foo }.to raise_error(RuntimeError) 55 | end 56 | end 57 | 58 | context "#deliver" do 59 | context "when mailer has from/to defined with DSL" do 60 | let(:mailer) { CharsetMailer.new(configuration: configuration) } 61 | 62 | it "delivers email with valid locals" do 63 | mail = mailer.deliver({}) 64 | expect(mail).to be_kind_of(Mail::Message) 65 | end 66 | 67 | it "is aliased as #call" do 68 | mail = mailer.call({}) 69 | expect(mail).to be_kind_of(Mail::Message) 70 | end 71 | 72 | it "raises error when locals are nil" do 73 | expect { mailer.deliver(nil) }.to raise_error(NoMethodError) 74 | end 75 | end 76 | 77 | context "when from/to are missing" do 78 | let(:mailer) { InvoiceMailer.new(configuration: configuration) } 79 | 80 | it "raises error" do 81 | expect { mailer.deliver({}) }.to raise_error(Hanami::Mailer::MissingDeliveryDataError) 82 | end 83 | end 84 | 85 | context "when using #{described_class} directly" do 86 | let(:mailer) do 87 | described_class.new(configuration: configuration) 88 | end 89 | 90 | it "raises error" do 91 | expect { mailer.deliver({}) }.to raise_error(NoMethodError) 92 | end 93 | end 94 | 95 | context "with non-finalized configuration" do 96 | let(:configuration) { Hanami::Mailer::Configuration.new } 97 | 98 | let(:mailer) do 99 | Class.new(described_class) do 100 | def self.name 101 | "anonymous_mailer" 102 | end 103 | end.new(configuration: configuration) 104 | end 105 | 106 | it "raises error" do 107 | expect { mailer.deliver({}) }.to raise_error(Hanami::Mailer::UnknownMailerError) 108 | end 109 | end 110 | 111 | context "locals" do 112 | let(:mailer) { EventMailer.new(configuration: configuration) } 113 | let(:user) { double(name: "Luca", email: "luca@domain.test") } 114 | let(:event) { double(id: 23, title: "Event #23") } 115 | 116 | it "uses locals during the delivery process" do 117 | mail = mailer.deliver(user: user, event: event) 118 | 119 | expect(mail.to).to eq(["luca@domain.test"]) 120 | expect(mail.subject).to eq("Invitation: Event #23") 121 | expect(mail.attachments[0].filename).to eq("invitation-23.ics") 122 | end 123 | end 124 | end 125 | 126 | describe "#render" do 127 | describe "when template is explicitly declared" do 128 | let(:mailer) { InvoiceMailer.new(configuration: configuration) } 129 | 130 | it "renders the given template" do 131 | expect(mailer.render(:html, {})).to include(%(

Invoice template

)) 132 | end 133 | end 134 | 135 | describe "when template is implicitly declared" do 136 | let(:mailer) { LazyMailer.new(configuration: configuration) } 137 | 138 | it "looks for template with same name with inflected classname and render it" do 139 | expect(mailer.render(:html, {})).to include(%(Hello World)) 140 | expect(mailer.render(:txt, {})).to include(%(This is a txt template)) 141 | end 142 | end 143 | 144 | describe "when mailer defines context" do 145 | let(:mailer) { WelcomeMailer.new(configuration: configuration) } 146 | 147 | it "renders template with defined context" do 148 | expect(mailer.render(:txt, {})).to include(%(Ahoy)) 149 | expect(mailer.render(:txt, {})).not_to include(%(MAIL-HEADER)) 150 | end 151 | end 152 | 153 | describe "when mailer defines static layout" do 154 | let(:mailer) { WithStaticLayoutMailer.new(configuration: configuration) } 155 | 156 | it "renders template inside defined layout" do 157 | expect(mailer.render(:txt, {})).to include(%(MAIL-HEADER)) 158 | expect(mailer.render(:txt, {})).to include(%(MAIL-FOOTER)) 159 | end 160 | end 161 | 162 | describe "when mailer defines dynamic layout" do 163 | let(:mailer) { WithDynamicLayoutMailer.new(configuration: configuration) } 164 | 165 | it "renders template inside defined layout with dynamic content" do 166 | expect(mailer.render(:txt, {layout_local: "DYNAMIC"})).to include(%(MAIL-HEADER-DYNAMIC)) 167 | expect(mailer.render(:txt, {layout_local: "DYNAMIC"})).to include(%(MAIL-FOOTER-DYNAMIC)) 168 | end 169 | end 170 | 171 | describe "when locals are parsed in" do 172 | let(:mailer) { RenderMailer.new(configuration: configuration) } 173 | let(:locals) { {user: User.new("Luca")} } 174 | 175 | it "renders template with parsed locals" do 176 | expect(mailer.render(:html, locals)).to include(locals.fetch(:user).name) 177 | end 178 | end 179 | 180 | describe "with HAML template engine" do 181 | let(:mailer) { TemplateEngineMailer.new(configuration: configuration) } 182 | let(:locals) { {user: User.new("MG")} } 183 | 184 | it "renders template with parsed locals" do 185 | expect(mailer.render(:html, locals)).to include(%(

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

\n)) 186 | end 187 | end 188 | end 189 | end 190 | -------------------------------------------------------------------------------- /lib/hanami/mailer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "mail" 4 | require "concurrent" 5 | 6 | # Hanami 7 | # 8 | # @since 0.1.0 9 | module Hanami 10 | # Hanami::Mailer 11 | # 12 | # @since 0.1.0 13 | class Mailer 14 | require "hanami/mailer/version" 15 | require "hanami/mailer/template" 16 | require "hanami/mailer/finalizer" 17 | require "hanami/mailer/configuration" 18 | require "hanami/mailer/dsl" 19 | 20 | # Content types mapping 21 | # 22 | # @since 0.1.0 23 | # @api private 24 | CONTENT_TYPES = { 25 | html: "text/html", 26 | txt: "text/plain" 27 | }.freeze 28 | 29 | private_constant(:CONTENT_TYPES) 30 | 31 | # Base error for Hanami::Mailer 32 | # 33 | # @since 0.1.0 34 | class Error < ::StandardError 35 | end 36 | 37 | # Unknown mailer 38 | # 39 | # This error is raised at the runtime when trying to deliver a mail message, 40 | # by using a configuration that it wasn't finalized yet. 41 | # 42 | # @since next 43 | # @api unstable 44 | # 45 | # @see Hanami::Mailer.finalize 46 | class UnknownMailerError < Error 47 | # @param mailer [Hanami::Mailer] a mailer 48 | # 49 | # @since next 50 | # @api unstable 51 | def initialize(mailer) 52 | super("Unknown mailer: #{mailer.inspect}. Please finalize the configuration before to use it.") 53 | end 54 | end 55 | 56 | # Missing delivery data error 57 | # 58 | # It's raised when a mailer doesn't specify `from` or `to`. 59 | # 60 | # @since 0.1.0 61 | class MissingDeliveryDataError < Error 62 | def initialize 63 | super("Missing delivery data, please check 'from', or 'to'") 64 | end 65 | end 66 | 67 | # @since next 68 | # @api unstable 69 | @_subclasses = Concurrent::Array.new 70 | 71 | # Override Ruby's hook for modules. 72 | # It includes basic `Hanami::Mailer` modules to the given Class. 73 | # It sets a copy of the framework configuration 74 | # 75 | # @param base [Class] the target mailer 76 | # 77 | # @since next 78 | # @api unstable 79 | def self.inherited(base) 80 | super 81 | @_subclasses.push(base) 82 | base.extend Dsl 83 | end 84 | 85 | private_class_method :inherited 86 | 87 | # Finalize the configuration 88 | # 89 | # This should be used before to start to use the mailers 90 | # 91 | # @param configuration [Hanami::Mailer::Configuration] the configuration to 92 | # finalize 93 | # 94 | # @return [Hanami::Mailer::Configuration] the finalized configuration 95 | # 96 | # @since next 97 | # @api unstable 98 | # 99 | # @example 100 | # require 'hanami/mailer' 101 | # 102 | # configuration = Hanami::Mailer::Configuration.new do |config| 103 | # # ... 104 | # end 105 | # 106 | # configuration = Hanami::Mailer.finalize(configuration) 107 | # MyMailer.new(configuration: configuration) 108 | def self.finalize(configuration) 109 | Finalizer.finalize(@_subclasses, configuration) 110 | end 111 | 112 | # Initialize a mailer 113 | # 114 | # @param configuration [Hanami::Mailer::Configuration] the configuration 115 | # @return [Hanami::Mailer] 116 | # 117 | # @since 0.1.0 118 | def initialize(configuration:) 119 | @configuration = configuration 120 | freeze 121 | end 122 | 123 | # Prepare the email message when a new mailer is initialized. 124 | # 125 | # @return [Mail::Message] the delivered email 126 | # 127 | # @since 0.1.0 128 | # @api unstable 129 | # 130 | # @see Hanami::Mailer::Configuration#default_charset 131 | # 132 | # @example 133 | # require 'hanami/mailer' 134 | # 135 | # configuration = Hanami::Mailer::Configuration.new do |config| 136 | # config.delivery_method = :smtp 137 | # end 138 | # 139 | # configuration = Hanami::Mailer.finalize(configuration) 140 | # 141 | # module Billing 142 | # class InvoiceMailer < Hanami::Mailer 143 | # from 'noreply@example.com' 144 | # to ->(locals) { locals.fetch(:user).email } 145 | # subject ->(locals) { "Invoice number #{locals.fetch(:invoice).number}" } 146 | # 147 | # before do |mail, locals| 148 | # mail.attachments["invoice-#{locals.fetch(:invoice).number}.pdf"] = 149 | # File.read('/path/to/invoice.pdf') 150 | # end 151 | # end 152 | # end 153 | # 154 | # invoice = Invoice.new(number: 23) 155 | # user = User.new(name: 'L', email: 'user@example.com') 156 | # 157 | # mailer = Billing::InvoiceMailer.new(configuration: configuration) 158 | # 159 | # # Deliver both text, HTML parts and the attachment 160 | # mailer.deliver(invoice: invoice, user: user) 161 | # 162 | # # Deliver only the text part and the attachment 163 | # mailer.deliver(invoice: invoice, user: user, format: :txt) 164 | # 165 | # # Deliver only the text part and the attachment 166 | # mailer.deliver(invoice: invoice, user: user, format: :html) 167 | # 168 | # # Deliver both the parts with "iso-8859" 169 | # mailer.deliver(invoice: invoice, user: user, charset: 'iso-8859') 170 | def deliver(locals) 171 | mail(locals).deliver 172 | rescue ArgumentError => exception 173 | raise MissingDeliveryDataError if exception.message =~ /SMTP (From|To) address/ 174 | 175 | raise 176 | end 177 | 178 | # @since next 179 | # @api unstable 180 | alias_method :call, :deliver 181 | 182 | # Render a single template with the specified format. 183 | # 184 | # @param format [Symbol] format 185 | # 186 | # @return [String] the output of the rendering process. 187 | # 188 | # @since 0.1.0 189 | # @api unstable 190 | def render(format, locals) 191 | rendered_content = template(format).render(self, locals) 192 | 193 | if (base_layout = layout_file(format)).nil? 194 | rendered_content 195 | else 196 | base_layout.render_layout(rendered_content, locals) 197 | end 198 | end 199 | 200 | private 201 | 202 | # @api unstable 203 | # @since next 204 | attr_reader :configuration 205 | 206 | # @api unstable 207 | # @since next 208 | def mail(locals) 209 | Mail.new.tap do |mail| 210 | instance_exec(mail, locals, &self.class.before) 211 | bind(mail, locals) 212 | end 213 | end 214 | 215 | # @api unstable 216 | # @since next 217 | # 218 | def bind(mail, locals) # rubocop:disable Metrics/AbcSize 219 | charset = locals.fetch(:charset, configuration.default_charset) 220 | 221 | mail.return_path = __dsl(:return_path, locals) 222 | mail.from = __dsl(:from, locals) 223 | mail.to = __dsl(:to, locals) 224 | mail.cc = __dsl(:cc, locals) 225 | mail.bcc = __dsl(:bcc, locals) 226 | mail.reply_to = __dsl(:reply_to, locals) 227 | mail.subject = __dsl(:subject, locals) 228 | 229 | mail.html_part = __part(:html, charset, locals) 230 | mail.text_part = __part(:txt, charset, locals) 231 | 232 | mail.charset = charset 233 | mail.delivery_method(*configuration.delivery_method) 234 | end 235 | 236 | # @since next 237 | # @api unstable 238 | def template(format) 239 | configuration.template(self.class, format) 240 | end 241 | 242 | # @since next 243 | # @api unstable 244 | def layout_file(format) 245 | configuration.layout(self.class, format) 246 | end 247 | 248 | # @since 0.1.0 249 | # @api unstable 250 | def __dsl(method_name, locals) 251 | case result = self.class.__send__(method_name) 252 | when Proc 253 | result.call(locals) 254 | else 255 | result 256 | end 257 | end 258 | 259 | # @since 0.1.0 260 | # @api unstable 261 | def __part(format, charset, locals) 262 | return unless __part?(format, locals) 263 | 264 | Mail::Part.new.tap do |part| 265 | part.content_type = "#{CONTENT_TYPES.fetch(format)}; charset=#{charset}" 266 | part.body = render(format, locals) 267 | end 268 | end 269 | 270 | # @since 0.1.0 271 | # @api unstable 272 | def __part?(format, locals) 273 | wanted = locals.fetch(:format, nil) 274 | wanted == format || 275 | (!wanted && !template(format).nil?) 276 | end 277 | end 278 | end 279 | -------------------------------------------------------------------------------- /lib/hanami/mailer/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "set" 4 | require "hanami/utils/kernel" 5 | require "hanami/mailer/template_name" 6 | require "hanami/mailer/templates_finder" 7 | 8 | module Hanami 9 | class Mailer 10 | # Framework configuration 11 | # 12 | # @since 0.1.0 13 | class Configuration 14 | # Default root 15 | # 16 | # @since 0.1.0 17 | # @api private 18 | DEFAULT_ROOT = "." 19 | 20 | # Default delivery method 21 | # 22 | # @since 0.1.0 23 | # @api private 24 | DEFAULT_DELIVERY_METHOD = :smtp 25 | 26 | # Default charset 27 | # 28 | # @since 0.1.0 29 | # @api private 30 | DEFAULT_CHARSET = "UTF-8" 31 | 32 | private_constant(*constants(false)) 33 | 34 | # Initialize a configuration instance 35 | # 36 | # @yield [config] the new initialized configuration instance 37 | # @return [Hanami::Mailer::Configuration] a new configuration's instance 38 | # 39 | # @since 0.1.0 40 | # 41 | # @example Basic Usage 42 | # require 'hanami/mailer' 43 | # 44 | # configuration = Hanami::Mailer::Configuration.new do |config| 45 | # config.delivery_method :smtp, ... 46 | # end 47 | def initialize 48 | @mailers = {} 49 | 50 | self.namespace = Object 51 | self.root = DEFAULT_ROOT 52 | self.delivery_method = DEFAULT_DELIVERY_METHOD 53 | self.default_charset = DEFAULT_CHARSET 54 | 55 | yield(self) if block_given? 56 | @finder = TemplatesFinder.new(root) 57 | end 58 | 59 | # Set the Ruby namespace where to lookup for mailers. 60 | # 61 | # When multiple instances of the framework are used, we want to make sure 62 | # that if a `MyApp` wants a `Mailers::Signup` mailer, we are loading the 63 | # right one. 64 | # 65 | # @!attribute namespace 66 | # @return [Class,Module,String] the Ruby namespace where the mailers 67 | # are located 68 | # 69 | # @since next 70 | # @api unstable 71 | # 72 | # @example 73 | # require 'hanami/mailer' 74 | # 75 | # Hanami::Mailer::Configuration.new do |config| 76 | # config.namespace = MyApp::Mailers 77 | # end 78 | attr_accessor :namespace 79 | 80 | # Set the root path where to search for templates 81 | # 82 | # If not set, this value defaults to the current directory. 83 | # 84 | # @param value [String, Pathname] the root path for mailer templates 85 | # 86 | # @raise [Errno::ENOENT] if the path doesn't exist 87 | # 88 | # @since next 89 | # @api unstable 90 | # 91 | # @see http://www.ruby-doc.org/stdlib/libdoc/pathname/rdoc/Pathname.html 92 | # @see http://rdoc.info/gems/hanami-utils/Hanami/Utils/Kernel#Pathname-class_method 93 | # 94 | # @example 95 | # require 'hanami/mailer' 96 | # 97 | # Hanami::Mailer::Configuration.new do |config| 98 | # config.root = 'path/to/templates' 99 | # end 100 | def root=(value) 101 | @root = Utils::Kernel.Pathname(value).realpath 102 | end 103 | 104 | # @!attribute [r] root 105 | # @return [Pathname] the root path for mailer templates 106 | # 107 | # @since next 108 | # @api unstable 109 | attr_reader :root 110 | 111 | # @param blk [Proc] the code block 112 | # 113 | # @return [void] 114 | # 115 | # @raise [ArgumentError] if called without passing a block 116 | # 117 | # @since 0.1.0 118 | # 119 | # @see Hanami::Mailer.configure 120 | def prepare(&blk) 121 | raise ArgumentError.new("Please provide a block") unless block_given? 122 | 123 | @modules.push(blk) 124 | end 125 | 126 | # Duplicate by copying the settings in a new instance. 127 | # 128 | # @return [Hanami::Mailer::Configuration] a copy of the configuration 129 | # 130 | # @since 0.1.0 131 | # @api private 132 | def duplicate 133 | Configuration.new.tap do |c| 134 | c.namespace = namespace 135 | c.root = root.dup 136 | c.modules = modules.dup 137 | c.delivery_method = delivery_method 138 | c.default_charset = default_charset 139 | end 140 | end 141 | 142 | # Load the configuration 143 | def load! 144 | mailers.each { |m| m.__send__(:load!) } 145 | freeze 146 | end 147 | 148 | # Reset the configuration 149 | def reset! 150 | root(DEFAULT_ROOT) 151 | delivery_method(DEFAULT_DELIVERY_METHOD) 152 | default_charset(DEFAULT_CHARSET) 153 | 154 | @mailers = Set.new 155 | @modules = [] 156 | end 157 | 158 | alias_method :unload!, :reset! 159 | 160 | # Copy the configuration for the given mailer 161 | # 162 | # @param base [Class] the target mailer 163 | # 164 | # @return void 165 | # 166 | # @since 0.1.0 167 | # @api private 168 | def copy!(base) 169 | modules.each do |mod| 170 | base.class_eval(&mod) 171 | end 172 | end 173 | 174 | # Specify a global delivery method for the mail gateway. 175 | # 176 | # It supports the following delivery methods: 177 | # 178 | # * Exim (`:exim`) 179 | # * Sendmail (`:sendmail`) 180 | # * SMTP (`:smtp`, for local installations) 181 | # * SMTP Connection (`:smtp_connection`, 182 | # via `Net::SMTP` - for remote installations) 183 | # * Test (`:test`, for testing purposes) 184 | # 185 | # The default delivery method is SMTP (`:smtp`). 186 | # 187 | # Custom delivery methods can be specified by passing the class policy and 188 | # a set of optional configurations. This class MUST respond to: 189 | # 190 | # * `initialize(options = {})` 191 | # * `deliver!(mail)` 192 | # 193 | # @param method [Symbol, #initialize, deliver!] delivery method 194 | # @param options [Hash] optional settings 195 | # 196 | # @return [Array] an array containing the delivery method and the optional settings as an Hash 197 | # 198 | # @since next 199 | # @api unstable 200 | # 201 | # @example Setup delivery method with supported symbol 202 | # require 'hanami/mailer' 203 | # 204 | # Hanami::Mailer::Configuration.new do |config| 205 | # config.delivery_method = :sendmail 206 | # end 207 | # 208 | # @example Setup delivery method with supported symbol and options 209 | # require 'hanami/mailer' 210 | # 211 | # Hanami::Mailer::Configuration.new do |config| 212 | # config.delivery_method = :smtp, address: "localhost", port: 1025 213 | # end 214 | # 215 | # @example Setup custom delivery method with options 216 | # require 'hanami/mailer' 217 | # 218 | # class MandrillDeliveryMethod 219 | # def initialize(options) 220 | # @options = options 221 | # end 222 | # 223 | # def deliver!(mail) 224 | # # ... 225 | # end 226 | # end 227 | # 228 | # Hanami::Mailer.Configuration.new do |config| 229 | # config.delivery_method = MandrillDeliveryMethod, 230 | # username: ENV['MANDRILL_USERNAME'], 231 | # password: ENV['MANDRILL_API_KEY'] 232 | # end 233 | attr_accessor :delivery_method 234 | 235 | # Specify a default charset for all the delivered emails 236 | # 237 | # If not set, it defaults to `UTF-8` 238 | # 239 | # @!attribute default_charset 240 | # @return [String] the charset 241 | # 242 | # @since next 243 | # @api unstable 244 | # 245 | # @example 246 | # require 'hanami/mailer' 247 | # 248 | # Hanami::Mailer::Configuration.new do |config| 249 | # config.default_charset = "iso-8859-1" 250 | # end 251 | attr_accessor :default_charset 252 | 253 | # Add a mailer to the registry 254 | # 255 | # @param mailer [Hanami::Mailer] a mailer 256 | # 257 | # @since 0.1.0 258 | # @api unstable 259 | def add_mailer(mailer) 260 | template_name = TemplateName[mailer.template_name, namespace] 261 | templates = finder.find(template_name) 262 | 263 | mailers[mailer] = templates 264 | end 265 | 266 | # @param mailer [Hanami::Mailer] a mailer 267 | # @param format [Symbol] the wanted format (eg. `:html`, `:txt`) 268 | # 269 | # @raise [Hanami::Mailer::UnknownMailerError] if the given mailer is not 270 | # present in the configuration. This happens when the configuration is 271 | # used before to being finalized. 272 | # 273 | # @since next 274 | # @api unstable 275 | def template(mailer, format) 276 | mailers.fetch(mailer) { raise UnknownMailerError.new(mailer) }[format] 277 | end 278 | 279 | # @param mailer [Hanami::Mailer] a mailer 280 | # @param format [Symbol] the wanted format (eg. `:html`, `:txt`) 281 | # 282 | # @since next 283 | # @api unstable 284 | def layout(mailer, format) 285 | unless mailer.layout_name.nil? 286 | finder.find(mailer.layout_name)[format] 287 | end 288 | end 289 | 290 | # Returns a new updated instance of the mailer with the given settings. 291 | # 292 | # @yieldparam [Hanami::Mailer::Configuration] the new configuration instance 293 | # 294 | # @raise [LocalJumpError] if no block is given 295 | # 296 | # @since x.x.x 297 | # @api unstable 298 | # 299 | # @example 300 | # require "hanami/mailer" 301 | # 302 | # configuration = Hanami::Mailer::Configuration.new 303 | # configuration.delivery_method # => :smtp 304 | # 305 | # updated = configuration.with do |config| 306 | # config.delivery_method = :test 307 | # end 308 | # 309 | # configuration.delivery_method # => :smtp 310 | # updated.delivery_method # => :test 311 | def with 312 | dup.tap do |new| 313 | yield(new) 314 | new.freeze 315 | end 316 | end 317 | 318 | # Deep freeze the important instance variables 319 | # 320 | # @since next 321 | # @api unstable 322 | def freeze 323 | delivery_method.freeze 324 | default_charset.freeze 325 | mailers.freeze 326 | super 327 | end 328 | 329 | private 330 | 331 | # @since 0.1.0 332 | # @api private 333 | attr_reader :mailers 334 | 335 | # @since next 336 | # @api unstable 337 | attr_reader :finder 338 | end 339 | end 340 | end 341 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hanami::Mailer 2 | 3 | Mail for Ruby applications. 4 | 5 | ## Version 6 | 7 | **This branch contains the code for `hanami-mailer` 2.x.** 8 | 9 | ## Status 10 | 11 | [![Gem Version](https://badge.fury.io/rb/hanami-mailer.svg)](https://badge.fury.io/rb/hanami-mailer) 12 | [![CI](https://github.com/hanami/mailer/workflows/ci/badge.svg?branch=main)](https://github.com/hanami/mailer/actions?query=workflow%3Aci+branch%3Amain) 13 | 14 | ## Contact 15 | 16 | * Home page: http://hanamirb.org 17 | * Mailing List: http://hanamirb.org/mailing-list 18 | * API Doc: http://rdoc.info/gems/hanami-mailer 19 | * Bugs/Issues: https://github.com/hanami/mailer/issues 20 | * Support: http://stackoverflow.com/questions/tagged/hanami 21 | * Chat: http://chat.hanamirb.org 22 | 23 | ## Installation 24 | 25 | Add this line to your application's Gemfile: 26 | 27 | ```ruby 28 | gem 'hanami-mailer' 29 | ``` 30 | 31 | And then execute: 32 | 33 | $ bundle 34 | 35 | Or install it yourself as: 36 | 37 | $ gem install hanami-mailer 38 | 39 | ## Usage 40 | 41 | ### Conventions 42 | 43 | * Templates are searched under `Hanami::Mailer::Configuration#root`, set this value according to your app structure (eg. `"app/templates"`). 44 | * A mailer will look for a template with a file name that is composed by its full class name (eg. `"articles/index"`). 45 | * A template must have two concatenated extensions: one for the format and one for the engine (eg. `".html.erb"`). 46 | * The framework must be loaded before rendering the first time: `Hanami::Mailer.finalize(configuration)`. 47 | 48 | ### Mailers 49 | 50 | A simple mailer looks like this: 51 | 52 | ```ruby 53 | require 'hanami/mailer' 54 | require 'ostruct' 55 | 56 | # Create two files: `invoice.html.erb` and `invoice.txt.erb` 57 | 58 | configuration = Hanami::Mailer::Configuration.new do |config| 59 | config.delivery_method = :test 60 | end 61 | 62 | class InvoiceMailer < Hanami::Mailer 63 | from "noreply@example.com" 64 | to ->(locals) { locals.fetch(:user).email } 65 | end 66 | 67 | configuration = Hanami::Mailer.finalize(configuration) 68 | 69 | invoice = OpenStruct.new(number: 23) 70 | mailer = InvoiceMailer.new(configuration: configuration) 71 | mail = mailer.deliver(invoice: invoice) 72 | 73 | mail 74 | # => #, , , , , >, , , , > 75 | 76 | mail.to_s 77 | # => 78 | # From: noreply@example.com 79 | # To: user@example.com 80 | # Message-ID: <58d25699e47f9_b4e13ff0c503e4f4632e6@escher.mail> 81 | # Subject: 82 | # Mime-Version: 1.0 83 | # Content-Type: multipart/alternative; 84 | # boundary="--==_mimepart_58d25699e42d2_b4e13ff0c503e4f463186"; 85 | # charset=UTF-8 86 | # Content-Transfer-Encoding: 7bit 87 | # 88 | # 89 | # ----==_mimepart_58d25699e42d2_b4e13ff0c503e4f463186 90 | # Content-Type: text/plain; 91 | # charset=UTF-8 92 | # Content-Transfer-Encoding: 7bit 93 | # 94 | # Invoice #23 95 | # 96 | # ----==_mimepart_58d25699e42d2_b4e13ff0c503e4f463186 97 | # Content-Type: text/html; 98 | # charset=UTF-8 99 | # Content-Transfer-Encoding: 7bit 100 | # 101 | # 102 | # 103 | #

Invoice template

104 | # 105 | # 106 | # 107 | # ----==_mimepart_58d25699e42d2_b4e13ff0c503e4f463186-- 108 | ``` 109 | 110 | A mailer with `.to` and `.from` addresses and mailer delivery: 111 | 112 | ```ruby 113 | require 'hanami/mailer' 114 | 115 | configuration = Hanami::Mailer::Configuration.new do |config| 116 | config.delivery_method = :smtp, 117 | address: "smtp.gmail.com", 118 | port: 587, 119 | domain: "example.com", 120 | user_name: ENV['SMTP_USERNAME'], 121 | password: ENV['SMTP_PASSWORD'], 122 | authentication: "plain", 123 | enable_starttls_auto: true 124 | end 125 | 126 | class WelcomeMailer < Hanami::Mailer 127 | return_path 'bounce@sender.com' 128 | from 'noreply@sender.com' 129 | to 'noreply@recipient.com' 130 | cc 'cc@sender.com' 131 | bcc 'alice@example.com' 132 | 133 | subject 'Welcome' 134 | end 135 | 136 | WelcomeMailer.new(configuration: configuration).call(locals) 137 | ``` 138 | 139 | ### Locals 140 | 141 | The set of objects passed in the `deliver` call are called `locals` and are available inside the mailer and the template. 142 | 143 | ```ruby 144 | require 'hanami/mailer' 145 | require 'ostruct' 146 | 147 | user = OpenStruct.new(name: Luca', email: 'user@hanamirb.org') 148 | 149 | class WelcomeMailer < Hanami::Mailer 150 | from 'noreply@sender.com' 151 | subject 'Welcome' 152 | to ->(locals) { locals.fetch(:user).email } 153 | end 154 | 155 | WelcomeMailer.new(configuration: configuration).deliver(user: luca) 156 | ``` 157 | 158 | The corresponding `erb` file: 159 | 160 | ```erb 161 | Hello <%= user.name %>! 162 | ``` 163 | 164 | ### Scope 165 | 166 | All public methods defined in the mailer are accessible from the template: 167 | 168 | ```ruby 169 | require 'hanami/mailer' 170 | 171 | class WelcomeMailer < Hanami::Mailer 172 | from 'noreply@sender.com' 173 | to 'noreply@recipient.com' 174 | subject 'Welcome' 175 | 176 | def greeting 177 | 'Ahoy' 178 | end 179 | end 180 | ``` 181 | 182 | ```erb 183 |

<%= greeting %>

184 | ``` 185 | 186 | ### Template 187 | 188 | The template file must be located under the relevant `root` and must match the inflected snake case of the mailer class name. 189 | 190 | ```ruby 191 | # Given this root 192 | configuration.root # => # 193 | 194 | # For InvoiceMailer, it looks for: 195 | # * app/templates/invoice_mailer.html.erb 196 | # * app/templates/invoice_mailer.txt.erb 197 | ``` 198 | 199 | If we want to specify a different template, we can do: 200 | 201 | ```ruby 202 | class InvoiceMailer < Hanami::Mailer 203 | template 'invoice' 204 | end 205 | 206 | # It will look for: 207 | # * app/templates/invoice.html.erb 208 | # * app/templates/invoice.txt.erb 209 | ``` 210 | 211 | ### Engines 212 | 213 | The builtin rendering engine is [ERb](http://en.wikipedia.org/wiki/ERuby). 214 | 215 | This is the list of the supported engines. 216 | They are listed in order of **higher precedence**, for a given extension. 217 | For instance, if [ERubis](http://www.kuwata-lab.com/erubis/) is loaded, it will be preferred over ERb to render `.erb` templates. 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 |
EngineExtensions
Erubiserb, rhtml, erubis
ERberb, rhtml
Redcarpetmarkdown, mkd, md
RDiscountmarkdown, mkd, md
Kramdownmarkdown, mkd, md
Marukumarkdown, mkd, md
BlueClothmarkdown, mkd, md
Asciidoctorad, adoc, asciidoc
Builderbuilder
CSVrcsv
CoffeeScriptcoffee
WikiClothwiki, mediawiki, mw
Creolewiki, creole
Etannietn, etanni
Hamlhaml
Lessless
Liquidliquid
Markabymab
Nokogirinokogiri
Plainhtml
RDocrdoc
Radiusradius
RedClothtextile
Sasssass
Scssscss
Slimslim
Stringstr
Yajlyajl
337 | 338 | 339 | ### Configuration 340 | 341 | __Hanami::Mailer__ can be configured with a DSL that determines its behavior. 342 | It supports a few options: 343 | 344 | ```ruby 345 | require "hanami/mailer" 346 | 347 | configuration = Hanami::Mailer::Configuration.new do |config| 348 | # Set the root path where to search for templates 349 | # Argument: String, Pathname, #to_pathname, defaults to the current directory 350 | # 351 | config.root = "path/to/root" 352 | 353 | # Set the default charset for emails 354 | # Argument: String, defaults to "UTF-8" 355 | # 356 | config.default_charset = "iso-8859" 357 | 358 | # Set the delivery method 359 | # Argument: Symbol 360 | # Argument: Hash, optional configurations 361 | config.delivery_method = :smtp 362 | end 363 | ``` 364 | 365 | ### Attachments 366 | 367 | Attachments can be added with the following API: 368 | 369 | ```ruby 370 | class InvoiceMailer < Hanami::Mailer 371 | # ... 372 | before do |mail, locals| 373 | mail.attachments["invoice-#{locals.fetch(:invoice).number}.pdf"] = 'path/to/invoice.pdf' 374 | end 375 | end 376 | ``` 377 | 378 | ### Delivery Method 379 | 380 | The global delivery method is defined through the __Hanami::Mailer__ configuration, as: 381 | 382 | ```ruby 383 | configuration = Hanami::Mailer::Configuration.new do |config| 384 | config.delivery_method = :smtp 385 | end 386 | ``` 387 | 388 | ```ruby 389 | configuration = Hanami::Mailer::Configuration.new do |config| 390 | config.delivery_method = :smtp, { address: "localhost", port: 1025 } 391 | end 392 | ``` 393 | 394 | Builtin options are: 395 | 396 | * Exim (`:exim`) 397 | * Sendmail (`:sendmail`) 398 | * SMTP (`:smtp`, for local installations) 399 | * SMTP Connection (`:smtp_connection`, via `Net::SMTP` - for remote installations) 400 | * Test (`:test`, for testing purposes) 401 | 402 | ### Custom Delivery Method 403 | 404 | Developers can specify their own custom delivery policy: 405 | 406 | ```ruby 407 | require 'hanami/mailer' 408 | 409 | class MandrillDeliveryMethod 410 | def initialize(options) 411 | @options = options 412 | end 413 | 414 | def deliver!(mail) 415 | # ... 416 | end 417 | end 418 | 419 | configuration = Hanami::Mailer::Configuration.new do |config| 420 | config.delivery_method = MandrillDeliveryMethod, 421 | username: ENV['MANDRILL_USERNAME'], 422 | password: ENV['MANDRILL_API_KEY'] 423 | end 424 | ``` 425 | 426 | The class passed to `.delivery_method=` must accept an optional set of options 427 | with the constructor (`#initialize`) and respond to `#deliver!`. 428 | 429 | ### Multipart Delivery 430 | 431 | All the email are sent as multipart messages by default. 432 | For a given mailer, the framework looks up for associated text (`.txt`) and `HTML` (`.html`) templates and render them. 433 | 434 | ```ruby 435 | InvoiceMailer.new(configuration: configuration).deliver({}) # delivers both text and html templates 436 | InvoiceMailer.new(configuration: configuration).deliver(format: :txt) # delivers only text template 437 | ``` 438 | 439 | Please note that **they aren't both mandatory, but at least one of them MUST** be present. 440 | 441 | ## Versioning 442 | 443 | __Hanami::Mailer__ uses [Semantic Versioning 2.0.0](http://semver.org) 444 | 445 | ## Copyright 446 | 447 | Copyright © 2015-2021 Luca Guidi – Released under MIT License 448 | 449 | This project was formerly known as Lotus (`lotus-mailer`). 450 | -------------------------------------------------------------------------------- /lib/hanami/mailer/dsl.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hanami 4 | # Hanami::Mailer 5 | # 6 | # @since 0.1.0 7 | class Mailer 8 | require "hanami/mailer/template_name" 9 | 10 | # Class level DSL 11 | # 12 | # @since 0.1.0 13 | module Dsl 14 | # @since 0.3.0 15 | # @api unstable 16 | def self.extended(base) 17 | base.class_eval do 18 | @from = nil 19 | @to = nil 20 | @cc = nil 21 | @bcc = nil 22 | @reply_to = nil 23 | @return_path = nil 24 | @subject = nil 25 | @template = nil 26 | @layout = nil 27 | @before = ->(*) {} 28 | end 29 | end 30 | 31 | private_class_method :extended 32 | 33 | # Sets the sender for mail messages 34 | # 35 | # It accepts a hardcoded value as a string, or a symbol that represents 36 | # an instance method for more complex logic. 37 | # 38 | # This value MUST be set, otherwise an exception is raised at the delivery 39 | # time. 40 | # 41 | # When a value is given, specify the sender of the email 42 | # Otherwise, it returns the sender of the email 43 | # 44 | # This is part of a DSL, for this reason when this method is called with 45 | # an argument, it will set the corresponding class variable. When 46 | # called without, it will return the already set value, or the default. 47 | # 48 | # @overload from(value) 49 | # Sets the sender 50 | # @param value [String, Symbol] the hardcoded value or method name 51 | # @return [NilClass] 52 | # 53 | # @overload from 54 | # Returns the sender 55 | # @return [String, Symbol] the sender 56 | # 57 | # @since 0.1.0 58 | # 59 | # @example Hardcoded value (String) 60 | # require 'hanami/mailer' 61 | # 62 | # class WelcomeMailer < Hanami::Mailer 63 | # from "noreply@example.com" 64 | # end 65 | # 66 | # @example Lazy (Proc) 67 | # require 'hanami/mailer' 68 | # 69 | # class WelcomeMailer < Hanami::Mailer 70 | # from ->(locals) { locals.fetch(:sender).email } 71 | # end 72 | def from(value = nil) 73 | if value.nil? 74 | @from 75 | else 76 | @from = value 77 | end 78 | end 79 | 80 | # Sets the recipient for mail messages 81 | # 82 | # It accepts a hardcoded value as a string or array of strings. 83 | # For dynamic values, you can specify a symbol that represents an instance 84 | # method. 85 | # 86 | # This value MUST be set, otherwise an exception is raised at the delivery 87 | # time. 88 | # 89 | # When a value is given, specify the recipient of the email 90 | # Otherwise, it returns the recipient of the email 91 | # 92 | # This is part of a DSL, for this reason when this method is called with 93 | # an argument, it will set the corresponding class variable. When 94 | # called without, it will return the already set value, or the default. 95 | # 96 | # @overload to(value) 97 | # Sets the recipient 98 | # @param value [String, Array, Symbol] the hardcoded value or method name 99 | # @return [NilClass] 100 | # 101 | # @overload to 102 | # Returns the recipient 103 | # @return [String, Array, Symbol] the recipient 104 | # 105 | # @since 0.1.0 106 | # 107 | # @example Hardcoded value (String) 108 | # require 'hanami/mailer' 109 | # 110 | # class WelcomeMailer < Hanami::Mailer 111 | # to "user@example.com" 112 | # end 113 | # 114 | # @example Hardcoded value (Array) 115 | # require 'hanami/mailer' 116 | # 117 | # class WelcomeMailer < Hanami::Mailer 118 | # to ["user-1@example.com", "user-2@example.com"] 119 | # end 120 | # 121 | # @example Lazy value (Proc) 122 | # require 'hanami/mailer' 123 | # 124 | # class WelcomeMailer < Hanami::Mailer 125 | # to ->(locals) { locals.fetch(:user).email } 126 | # end 127 | # 128 | # user = User.new(name: 'L') 129 | # WelcomeMailer.new(configuration: configuration).deliver(user: user) 130 | # 131 | # @example Lazy values (Proc) 132 | # require 'hanami/mailer' 133 | # 134 | # class WelcomeMailer < Hanami::Mailer 135 | # to ->(locals) { locals.fetch(:users).map(&:email) } 136 | # end 137 | # 138 | # users = [User.new(name: 'L'), User.new(name: 'MG')] 139 | # WelcomeMailer.new(configuration: configuration).deliver(users: users) 140 | def to(value = nil) 141 | if value.nil? 142 | @to 143 | else 144 | @to = value 145 | end 146 | end 147 | 148 | # Sets the cc (carbon copy) for mail messages 149 | # 150 | # It accepts a hardcoded value as a string or array of strings. 151 | # For dynamic values, you can specify a symbol that represents an instance 152 | # method. 153 | # 154 | # This value is optional. 155 | # 156 | # When a value is given, it specifies the cc for the email. 157 | # When a value is not given, it returns the cc of the email. 158 | # 159 | # This is part of a DSL, for this reason when this method is called with 160 | # an argument, it will set the corresponding class variable. When 161 | # called without, it will return the already set value, or the default. 162 | # 163 | # @overload cc(value) 164 | # Sets the cc 165 | # @param value [String, Array, Symbol] the hardcoded value or method name 166 | # @return [NilClass] 167 | # 168 | # @overload cc 169 | # Returns the cc 170 | # @return [String, Array, Symbol] the recipient 171 | # 172 | # @since 0.3.0 173 | # 174 | # @example Hardcoded value (String) 175 | # require 'hanami/mailer' 176 | # 177 | # class WelcomeMailer < Hanami::Mailer 178 | # cc "other.user@example.com" 179 | # end 180 | # 181 | # @example Hardcoded value (Array) 182 | # require 'hanami/mailer' 183 | # 184 | # class WelcomeMailer < Hanami::Mailer 185 | # cc ["other.user-1@example.com", "other.user-2@example.com"] 186 | # end 187 | # 188 | # @example Lazy value (Proc) 189 | # require 'hanami/mailer' 190 | # 191 | # class WelcomeMailer < Hanami::Mailer 192 | # cc ->(locals) { locals.fetch(:user).email } 193 | # end 194 | # 195 | # user = User.new(name: 'L') 196 | # WelcomeMailer.new(configuration: configuration).deliver(user: user) 197 | # 198 | # @example Lazy values (Proc) 199 | # require 'hanami/mailer' 200 | # 201 | # class WelcomeMailer < Hanami::Mailer 202 | # cc ->(locals) { locals.fetch(:users).map(&:email) } 203 | # end 204 | # 205 | # users = [User.new(name: 'L'), User.new(name: 'MG')] 206 | # WelcomeMailer.new(configuration: configuration).deliver(users: users) 207 | def cc(value = nil) 208 | if value.nil? 209 | @cc 210 | else 211 | @cc = value 212 | end 213 | end 214 | 215 | # Sets the bcc (blind carbon copy) for mail messages 216 | # 217 | # It accepts a hardcoded value as a string or array of strings. 218 | # For dynamic values, you can specify a symbol that represents an instance 219 | # method. 220 | # 221 | # This value is optional. 222 | # 223 | # When a value is given, it specifies the bcc for the email. 224 | # When a value is not given, it returns the bcc of the email. 225 | # 226 | # This is part of a DSL, for this reason when this method is called with 227 | # an argument, it will set the corresponding class variable. When 228 | # called without, it will return the already set value, or the default. 229 | # 230 | # @overload bcc(value) 231 | # Sets the bcc 232 | # @param value [String, Array, Symbol] the hardcoded value or method name 233 | # @return [NilClass] 234 | # 235 | # @overload bcc 236 | # Returns the bcc 237 | # @return [String, Array, Symbol] the recipient 238 | # 239 | # @since 0.3.0 240 | # 241 | # @example Hardcoded value (String) 242 | # require 'hanami/mailer' 243 | # 244 | # class WelcomeMailer < Hanami::Mailer 245 | # bcc "other.user@example.com" 246 | # end 247 | # 248 | # @example Hardcoded value (Array) 249 | # require 'hanami/mailer' 250 | # 251 | # class WelcomeMailer < Hanami::Mailer 252 | # bcc ["other.user-1@example.com", "other.user-2@example.com"] 253 | # end 254 | # 255 | # @example Lazy value (Proc) 256 | # require 'hanami/mailer' 257 | # 258 | # class WelcomeMailer < Hanami::Mailer 259 | # bcc ->(locals) { locals.fetch(:user).email } 260 | # end 261 | # 262 | # user = User.new(name: 'L') 263 | # WelcomeMailer.new(configuration: configuration).deliver(user: user) 264 | # 265 | # @example Lazy values (Proc) 266 | # require 'hanami/mailer' 267 | # 268 | # class WelcomeMailer < Hanami::Mailer 269 | # bcc ->(locals) { locals.fetch(:users).map(&:email) } 270 | # end 271 | # 272 | # users = [User.new(name: 'L'), User.new(name: 'MG')] 273 | # WelcomeMailer.new(configuration: configuration).deliver(users: users) 274 | def bcc(value = nil) 275 | if value.nil? 276 | @bcc 277 | else 278 | @bcc = value 279 | end 280 | end 281 | 282 | # Sets the reply_to for mail messages 283 | # 284 | # It accepts a hardcoded value as a string or array of strings. 285 | # For dynamic values, you can specify a symbol that represents an instance 286 | # method. 287 | # 288 | # This value is optional. 289 | # 290 | # When a value is given, it specifies the reply_to for the email. 291 | # When a value is not given, it returns the reply_to of the email. 292 | # 293 | # This is part of a DSL, for this reason when this method is called with 294 | # an argument, it will set the corresponding class variable. When 295 | # called without, it will return the already set value, or the default. 296 | # 297 | # @overload reply_to(value) 298 | # Sets the reply_to 299 | # @param value [String, Array, Symbol] the hardcoded value or method name 300 | # @return [NilClass] 301 | # 302 | # @overload reply_to 303 | # Returns the reply_to 304 | # @return [String, Array, Symbol] the recipient 305 | # 306 | # @since 1.3.0 307 | # 308 | # @example Hardcoded value (String) 309 | # require 'hanami/mailer' 310 | # 311 | # class WelcomeMailer 312 | # include Hanami::Mailer 313 | # 314 | # to "user@example.com" 315 | # reply_to "other.user@example.com" 316 | # end 317 | # 318 | # @example Hardcoded value (Array) 319 | # require 'hanami/mailer' 320 | # 321 | # class WelcomeMailer 322 | # include Hanami::Mailer 323 | # 324 | # to ["user-1@example.com", "user-2@example.com"] 325 | # reply_to ["other.user-1@example.com", "other.user-2@example.com"] 326 | # end 327 | # 328 | # @example Method (Symbol) 329 | # require 'hanami/mailer' 330 | # 331 | # class WelcomeMailer 332 | # include Hanami::Mailer 333 | # to "user@example.com" 334 | # reply_to :email_address 335 | # 336 | # private 337 | # 338 | # def email_address 339 | # user.email 340 | # end 341 | # end 342 | # 343 | # other_user = User.new(name: 'L') 344 | # WelcomeMailer.deliver(user: other_user) 345 | # 346 | # @example Method that returns a collection of recipients 347 | # require 'hanami/mailer' 348 | # 349 | # class WelcomeMailer 350 | # include Hanami::Mailer 351 | # to "user@example.com" 352 | # reply_to :recipients 353 | # 354 | # private 355 | # 356 | # def recipients 357 | # users.map(&:email) 358 | # end 359 | # end 360 | # 361 | # other_users = [User.new(name: 'L'), User.new(name: 'MG')] 362 | # WelcomeMailer.deliver(users: other_users) 363 | def reply_to(value = nil) 364 | if value.nil? 365 | @reply_to 366 | else 367 | @reply_to = value 368 | end 369 | end 370 | 371 | # Sets the MAIL FROM address for mail messages. 372 | # This lets you specify a "bounce address" different from the sender 373 | # address specified with `from`. 374 | # 375 | # It accepts a hardcoded value as a string, or a symbol that represents 376 | # an instance method for more complex logic. 377 | # 378 | # This value is optional. 379 | # 380 | # When a value is given, specify the MAIL FROM address of the email 381 | # Otherwise, it returns the MAIL FROM address of the email 382 | # 383 | # This is part of a DSL, for this reason when this method is called with 384 | # an argument, it will set the corresponding class variable. When 385 | # called without, it will return the already set value, or the default. 386 | # 387 | # @overload return_path(value) 388 | # Sets the MAIL FROM address 389 | # @param value [String, Symbol] the hardcoded value or method name 390 | # @return [NilClass] 391 | # 392 | # @overload return_path 393 | # Returns the MAIL FROM address 394 | # @return [String, Symbol] the MAIL FROM address 395 | # 396 | # @since 1.3.2 397 | # 398 | # @example Hardcoded value (String) 399 | # require 'hanami/mailer' 400 | # 401 | # class WelcomeMailer 402 | # include Hanami::Mailer 403 | # 404 | # return_path "bounce@example.com" 405 | # end 406 | # 407 | # @example Method (Symbol) 408 | # require 'hanami/mailer' 409 | # 410 | # class WelcomeMailer 411 | # include Hanami::Mailer 412 | # return_path :bounce_address 413 | # 414 | # private 415 | # 416 | # def bounce_address 417 | # "bounce@example.com" 418 | # end 419 | # end 420 | def return_path(value = nil) 421 | if value.nil? 422 | @return_path 423 | else 424 | @return_path = value 425 | end 426 | end 427 | 428 | # Sets the subject for mail messages 429 | # 430 | # It accepts a hardcoded value as a string, or a symbol that represents 431 | # an instance method for more complex logic. 432 | # 433 | # This value MUST be set, otherwise an exception is raised at the delivery 434 | # time. 435 | # 436 | # This is part of a DSL, for this reason when this method is called with 437 | # an argument, it will set the corresponding class variable. When 438 | # called without, it will return the already set value, or the default. 439 | # 440 | # @overload subject(value) 441 | # Sets the subject 442 | # @param value [String, Symbol] the hardcoded value or method name 443 | # @return [NilClass] 444 | # 445 | # @overload subject 446 | # Returns the subject 447 | # @return [String, Symbol] the subject 448 | # 449 | # @since 0.1.0 450 | # 451 | # @example Hardcoded value (String) 452 | # require 'hanami/mailer' 453 | # 454 | # class WelcomeMailer < Hanami::Mailer 455 | # subject "Welcome" 456 | # end 457 | # 458 | # @example Lazy value (Proc) 459 | # require 'hanami/mailer' 460 | # 461 | # class WelcomeMailer < Hanami::Mailer 462 | # subject ->(locals) { "Hello #{locals.fetch(:user).name}" } 463 | # end 464 | # 465 | # user = User.new(name: 'L') 466 | # WelcomeMailer.new(configuration: configuration).deliver(user: user) 467 | def subject(value = nil) 468 | if value.nil? 469 | @subject 470 | else 471 | @subject = value 472 | end 473 | end 474 | 475 | # Set the template name **IF** it differs from the naming convention. 476 | # 477 | # For a given mailer named `Signup::Welcome` it will look for 478 | # `signup/welcome.*.*` templates under the root directory. 479 | # 480 | # If for some reason, we need to specify a different template name, we can 481 | # use this method. 482 | # 483 | # @param value [String] the template name 484 | # 485 | # @since 0.1.0 486 | # @api unstable 487 | # 488 | # @example Custom template name 489 | # require 'hanami/mailer' 490 | # 491 | # class MyMailer < Hanami::Mailer 492 | # template 'mailer' 493 | # end 494 | def template(value) 495 | @template = value 496 | end 497 | 498 | # @since next 499 | # @api unstable 500 | def template_name 501 | @template || name 502 | end 503 | 504 | # Set the layout name to use 505 | # 506 | # If you need several mailers to share the same global layout, 507 | # use this method to define the file to render. 508 | # 509 | # The layout file must then use <%= yield %> to include the mailer specific content. 510 | # Default behaviour if nothing is provided is to use the template directly, without any layout. 511 | # 512 | # For a layout named `default_mailer`, it will look for 513 | # `default_mailer.*.*` files under the root directory. 514 | # 515 | # @param value [String] the layout name 516 | # 517 | # @since 2.0.0 518 | # @api unstable 519 | # 520 | # @example Custom layout name 521 | # require 'hanami/mailer' 522 | # 523 | # class MyMailer < Hanami::Mailer 524 | # layout 'mailer_layout' 525 | # end 526 | def layout(value) 527 | @layout = value 528 | end 529 | 530 | def layout_name 531 | @layout 532 | end 533 | 534 | # Before callback for email delivery 535 | # 536 | # @since next 537 | # @api unstable 538 | # 539 | # @example 540 | # require 'hanami/mailer' 541 | # 542 | # module Billing 543 | # class InvoiceMailer < Hanami::Mailer 544 | # subject 'Invoice' 545 | # from 'noreply@example.com' 546 | # to ->(locals) { locals.fetch(:user).email } 547 | # 548 | # before do |mail, locals| 549 | # user = locals.fetch(:user) 550 | # mail.attachments["invoice-#{invoice_code}-#{user.id}.pdf"] = File.read('/path/to/invoice.pdf') 551 | # end 552 | # 553 | # def invoice_code 554 | # "123" 555 | # end 556 | # end 557 | # end 558 | # 559 | # invoice = Invoice.new 560 | # user = User.new(name: 'L', email: 'user@example.com') 561 | # 562 | # InvoiceMailer.new(configuration: configuration).deliver(invoice: invoice, user: user) 563 | def before(&blk) 564 | if block_given? 565 | @before = blk 566 | else 567 | @before 568 | end 569 | end 570 | end 571 | end 572 | end 573 | --------------------------------------------------------------------------------