├── .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 | [](https://badge.fury.io/rb/hanami-mailer)
12 | [](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 | | Engine |
222 | Extensions |
223 |
224 |
225 | | Erubis |
226 | erb, rhtml, erubis |
227 |
228 |
229 | | ERb |
230 | erb, rhtml |
231 |
232 |
233 | | Redcarpet |
234 | markdown, mkd, md |
235 |
236 |
237 | | RDiscount |
238 | markdown, mkd, md |
239 |
240 |
241 | | Kramdown |
242 | markdown, mkd, md |
243 |
244 |
245 | | Maruku |
246 | markdown, mkd, md |
247 |
248 |
249 | | BlueCloth |
250 | markdown, mkd, md |
251 |
252 |
253 | | Asciidoctor |
254 | ad, adoc, asciidoc |
255 |
256 |
257 | | Builder |
258 | builder |
259 |
260 |
261 | | CSV |
262 | rcsv |
263 |
264 |
265 | | CoffeeScript |
266 | coffee |
267 |
268 |
269 | | WikiCloth |
270 | wiki, mediawiki, mw |
271 |
272 |
273 | | Creole |
274 | wiki, creole |
275 |
276 |
277 | | Etanni |
278 | etn, etanni |
279 |
280 |
281 | | Haml |
282 | haml |
283 |
284 |
285 | | Less |
286 | less |
287 |
288 |
289 | | Liquid |
290 | liquid |
291 |
292 |
293 | | Markaby |
294 | mab |
295 |
296 |
297 | | Nokogiri |
298 | nokogiri |
299 |
300 |
301 | | Plain |
302 | html |
303 |
304 |
305 | | RDoc |
306 | rdoc |
307 |
308 |
309 | | Radius |
310 | radius |
311 |
312 |
313 | | RedCloth |
314 | textile |
315 |
316 |
317 | | Sass |
318 | sass |
319 |
320 |
321 | | Scss |
322 | scss |
323 |
324 |
325 | | Slim |
326 | slim |
327 |
328 |
329 | | String |
330 | str |
331 |
332 |
333 | | Yajl |
334 | yajl |
335 |
336 |
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 |
--------------------------------------------------------------------------------