├── .rspec ├── .yardopts ├── lib ├── capybara │ ├── email │ │ ├── version.rb │ │ ├── rspec.rb │ │ ├── node.rb │ │ ├── dsl.rb │ │ └── driver.rb │ ├── email.rb │ └── node │ │ └── email.rb └── capybara-email.rb ├── .travis.yml ├── Rakefile ├── .github ├── workflows │ ├── ci.yml │ ├── lint.yml │ └── test.yml ├── PULL_REQUEST_TEMPLATE.md └── ISSUE_TEMPLATE.md ├── Gemfile ├── .gitignore ├── spec ├── spec_helper.rb ├── capybara │ └── node │ │ └── email_spec.rb └── email │ └── driver_spec.rb ├── CHANGELOG.md ├── .rubocop.yml ├── capybara-email.gemspec ├── LICENSE ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --colour 2 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --markup-provider=redcarpet 2 | --markup=markdown 3 | -------------------------------------------------------------------------------- /lib/capybara/email/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Capybara 4 | module Email 5 | VERSION = '3.0.2' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/capybara-email.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'capybara' 4 | require 'mail' 5 | 6 | module Capybara 7 | autoload :Email, 'capybara/email' 8 | end 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | rvm: 2 | - 2.0.0 3 | - 2.1.10 4 | - 2.2.10 5 | - 2.3.8 6 | - 2.4.10 7 | - 2.5.8 8 | - 2.6.6 9 | - 2.7.1 10 | - ruby-head 11 | 12 | cache: bundler 13 | 14 | matrix: 15 | allow_failures: 16 | - rvm: ruby-head 17 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/setup' 4 | require 'rspec/core/rake_task' 5 | 6 | Bundler::GemHelper.install_tasks 7 | 8 | RSpec::Core::RakeTask.new('default') do |t| 9 | t.pattern = FileList['spec/**/*_spec.rb'] 10 | end 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | pull_request: 6 | push: 7 | 8 | jobs: 9 | lint: 10 | name: Lint 11 | uses: ./.github/workflows/lint.yml 12 | 13 | test: 14 | name: Test 15 | uses: ./.github/workflows/test.yml 16 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | 7 | gem 'actionmailer', '> 3.0' 8 | gem 'bourne' 9 | gem 'byebug' if RUBY_VERSION >= '2.0' 10 | gem 'rake' 11 | gem 'rspec' 12 | gem 'rubocop' 13 | gem 'rubocop-rspec' 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | *.sw? 19 | bin/* 20 | binstubs/* 21 | bundler_stubs/* 22 | -------------------------------------------------------------------------------- /lib/capybara/email/rspec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'capybara/email' 4 | 5 | RSpec.configure do |config| 6 | config.include Capybara::Email::DSL, type: :acceptance 7 | config.include Capybara::Email::DSL, type: :feature 8 | config.include Capybara::Email::DSL, type: :request 9 | config.include Capybara::Email::DSL, type: :system 10 | end 11 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Lint 3 | 4 | on: 5 | workflow_call: 6 | 7 | jobs: 8 | lint-ruby: 9 | name: RuboCop 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Set up Ruby 15 | uses: ruby/setup-ruby@v1 16 | with: 17 | ruby-version: 3.4 18 | bundler-cache: true 19 | 20 | - name: Run RuboCop linter 21 | run: bundle exec rubocop 22 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubygems' 4 | require 'bundler/setup' 5 | 6 | begin 7 | require 'byebug' 8 | rescue LoadError 9 | # do nothing 10 | end 11 | 12 | RSpec.configure do |config| 13 | config.mock_with :mocha 14 | end 15 | 16 | require 'mail' 17 | require 'bourne' 18 | require 'action_mailer' 19 | require 'capybara/rspec' 20 | require 'capybara/email/rspec' 21 | 22 | Mail.defaults do 23 | delivery_method :test 24 | end 25 | 26 | RSpec.configure do |config| 27 | config.mock_with :mocha 28 | end 29 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | Closes # . 12 | 13 | ## Changes proposed in this pull request 14 | 15 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Test 3 | 4 | on: 5 | workflow_call: 6 | 7 | jobs: 8 | test: 9 | name: RSpec 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | ruby-version: ['2.7', '3.0', '3.1', '3.2', '3.3', '3.4'] 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Set up Ruby ${{ matrix.ruby-version }} 19 | uses: ruby/setup-ruby@v1 20 | with: 21 | ruby-version: ${{ matrix.ruby-version }} 22 | bundler-cache: true 23 | 24 | - name: Run RSpec specs 25 | run: bundle exec rspec 26 | -------------------------------------------------------------------------------- /lib/capybara/email.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Capybara 4 | module Node 5 | autoload :Email, 'capybara/node/email' 6 | end 7 | 8 | module Email 9 | autoload :DSL, 'capybara/email/dsl' 10 | autoload :Node, 'capybara/email/node' 11 | autoload :Driver, 'capybara/email/driver' 12 | autoload :Version, 'capybara/email/version' 13 | end 14 | end 15 | 16 | if defined?(ActionMailer) 17 | # Rails 4's ActionMailer::Base is autoloaded 18 | # so in the test suite the Mail constant is not 19 | # available untl ActionMailer::Base is eval'd 20 | # So we must eager-load it 21 | ActionMailer::Base 22 | end 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Unreleased 2 | 3 | * Avoid modifying frozen strings ([yuri-zubov][yuri-zubov]) 4 | * Define `#respond_to_missing?` on `Capybara::Node::Email` 5 | ([tylerhunt][tylerhunt]) 6 | 7 | ## 3.0.0 8 | 9 | * Update for Capybara 3 compatibility 10 | 11 | ## 2.4.0 12 | 13 | * Updated Capybara 14 | 15 | ## 2.3.0 16 | 17 | * Adds `Capybara::Node::Email#header` and `Capybara::Node::Email#headers` for 18 | retrieving optional headers set on an email. 19 | * Corrects `inspect` of `Capybara::Node::Email` 20 | * Delegate all missing methods in `Capybara::Node::Email` to `base.email` 21 | 22 | [tylerhunt]: https://github.com/tylerhunt 23 | [yuri-zubov]: https://github.com/yuri-zubov 24 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | --- 2 | plugins: 3 | - rubocop-rspec 4 | 5 | AllCops: 6 | NewCops: enable 7 | 8 | Lint/DuplicateBranch: 9 | IgnoreLiteralBranches: true 10 | 11 | Naming/FileName: 12 | Exclude: 13 | - 'lib/capybara-email.rb' 14 | 15 | Metrics/AbcSize: 16 | CountRepeatedAttributes: false 17 | 18 | Metrics/MethodLength: 19 | CountAsOne: ['array', 'hash', 'heredoc', 'method_call'] 20 | Max: 15 21 | 22 | RSpec/ExampleLength: 23 | Max: 10 24 | 25 | RSpec/MultipleExpectations: 26 | Max: 5 27 | 28 | Style/BlockDelimiters: 29 | EnforcedStyle: semantic 30 | 31 | Style/Documentation: 32 | Enabled: false 33 | 34 | Style/MultilineBlockChain: 35 | Enabled: false 36 | 37 | Style/TrailingCommaInArguments: 38 | EnforcedStyleForMultiline: consistent_comma 39 | -------------------------------------------------------------------------------- /capybara-email.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require File.expand_path('lib/capybara/email/version', __dir__) 4 | 5 | Gem::Specification.new do |gem| 6 | gem.authors = ['Brian Cardarella'] 7 | gem.email = %w[bcardarella@gmail.com brian@dockyard.com] 8 | gem.description = 'Test your ActionMailer and Mailer messages in Capybara' 9 | gem.summary = 'Test your ActionMailer and Mailer messages in Capybara' 10 | gem.homepage = 'https://github.com/dockyard/capybara-email' 11 | gem.license = 'MIT' 12 | gem.required_ruby_version = '>= 2.7' 13 | 14 | gem.files = Dir['lib/**/*.rb', 'LICENSE', 'README.md'] 15 | gem.name = 'capybara-email' 16 | gem.require_paths = ['lib'] 17 | gem.version = Capybara::Email::VERSION 18 | 19 | gem.add_dependency 'capybara', '>= 2.4', '< 4.0' 20 | gem.add_dependency 'mail' 21 | 22 | gem.metadata['rubygems_mfa_required'] = 'true' 23 | end 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 DockYard, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 20 | 21 | 22 | ## Version 23 | 24 | 25 | ## Test Case 26 | 27 | 28 | ## Steps to reproduce 29 | 30 | 31 | ## Expected Behavior 32 | 33 | 34 | ## Actual Behavior 35 | 36 | -------------------------------------------------------------------------------- /lib/capybara/email/node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Capybara 4 | module Email 5 | class Node < Capybara::Driver::Node 6 | def text 7 | native.text 8 | end 9 | 10 | def [](name) 11 | string_node[name] 12 | end 13 | 14 | def value 15 | string_node.value 16 | end 17 | 18 | def visible_text 19 | normalize_whitespace(unnormalized_text) 20 | end 21 | 22 | def all_text 23 | normalize_whitespace(text) 24 | end 25 | 26 | def click(_keys = [], _options = {}) 27 | driver.follow(self[:href].to_s) 28 | end 29 | 30 | def tag_name 31 | native.node_name 32 | end 33 | 34 | def visible? 35 | string_node.visible? 36 | end 37 | 38 | def disabled? 39 | string_node.disabled? 40 | end 41 | 42 | def find(locator) 43 | native.xpath(locator).map { |node| self.class.new(driver, node) } 44 | end 45 | alias find_xpath find 46 | 47 | protected 48 | 49 | def unnormalized_text 50 | if !visible? 51 | '' 52 | elsif native.text? 53 | native.text 54 | elsif native.element? 55 | native.children.map { |child| 56 | Capybara::Email::Node.new(driver, child).unnormalized_text 57 | }.join 58 | else 59 | '' 60 | end 61 | end 62 | 63 | private 64 | 65 | def normalize_whitespace(text) 66 | text.to_s.gsub(/[[:space:]]+/, ' ').strip 67 | end 68 | 69 | def string_node 70 | @string_node ||= Capybara::Node::Simple.new(native) 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/capybara/email/dsl.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Capybara 4 | module Email 5 | module DSL 6 | # Returns the currently set email. 7 | # If no email set will return nil. 8 | # 9 | # @return [Mail::Message, nil] 10 | attr_accessor :current_email 11 | attr_writer :current_emails 12 | 13 | # Access all emails 14 | # 15 | # @return [Array] 16 | def all_emails 17 | Mail::TestMailer.deliveries 18 | end 19 | 20 | # Access all emails for a recipient. 21 | # 22 | # @param [String] 23 | # 24 | # @return [Array] 25 | def emails_sent_to(recipient) 26 | self.current_emails = all_emails.select { |email| 27 | [email.to, email.cc, email.bcc].flatten.compact.include?(recipient) 28 | }.map { |email| 29 | driver = Capybara::Email::Driver.new(email) 30 | Capybara::Node::Email.new(Capybara.current_session, driver) 31 | } 32 | end 33 | 34 | # Access the first email for a recipient and set it to. 35 | # 36 | # @param [String] 37 | # 38 | # @return [Mail::Message] 39 | def first_email_sent_to(recipient) 40 | self.current_email = emails_sent_to(recipient).last 41 | end 42 | alias open_email first_email_sent_to 43 | 44 | # Returns a collection of all current emails retrieved 45 | # 46 | # @return [Array] 47 | def current_emails 48 | @current_emails || [] 49 | end 50 | 51 | # Clear the email queue 52 | def clear_emails 53 | all_emails.clear 54 | self.current_emails = nil 55 | self.current_email = nil 56 | end 57 | alias clear_email clear_emails 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | ## Improve documentation 4 | 5 | We are always looking to improve our documentation. If at some moment you are 6 | reading the documentation and something is not clear, or you can't find what you 7 | are looking for, then please open an issue with the repository. This gives us a 8 | chance to answer your question and to improve the documentation if needed. 9 | 10 | Pull requests correcting spelling or grammar mistakes are always welcome. 11 | 12 | ## Found a bug? 13 | 14 | Please try to answer at least the following questions when reporting a bug: 15 | 16 | - Which version of the project did you use when you noticed the bug? 17 | - How do you reproduce the error condition? 18 | - What happened that you think is a bug? 19 | - What should it do instead? 20 | 21 | It would really help the maintainers if you could provide a reduced test case 22 | that reproduces the error condition. 23 | 24 | ## Have a feature request? 25 | 26 | Please provide some thoughful commentary and code samples on what this feature 27 | should do and why it should be added (your use case). The minimal questions you 28 | should answer when submitting a feature request should be: 29 | 30 | - What will it allow you to do that you can't do today? 31 | - Why do you need this feature and how will it benefit other users? 32 | - Are there any drawbacks to this feature? 33 | 34 | ## Submitting a pull-request? 35 | 36 | Here are some things that will increase the chance that your pull-request will 37 | get accepted: 38 | - Did you confirm this fix/feature is something that is needed? 39 | - Did you write tests, preferably in a test driven style? 40 | - Did you add documentation for the changes you made? 41 | - Did you follow our [styleguide](https://github.com/dockyard/styleguides)? 42 | 43 | If your pull-request addresses an issue then please add the corresponding 44 | issue's number to the description of your pull-request. 45 | 46 | # How to work with this project locally 47 | 48 | ## Installation 49 | 50 | First clone this repository: 51 | 52 | ```sh 53 | git clone https://github.com/DockYard/capybara-email.git 54 | ``` 55 | 56 | 57 | 58 | ## Running tests 59 | 60 | 61 | -------------------------------------------------------------------------------- /lib/capybara/node/email.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Capybara 4 | module Node 5 | class Email < Capybara::Node::Document 6 | # Delegate to the email body 7 | # 8 | # @return [Mail::Message#body] 9 | def body 10 | base.raw 11 | end 12 | 13 | # Treat the message's body as a Capybara::Node::Simple so that 14 | # selectors actually work instead of raising NotImplementedErrors 15 | def body_as_simple_node 16 | @body_as_simple_node ||= Capybara.string base.raw 17 | end 18 | 19 | # Returns the value of the passed in header key. 20 | # 21 | # @return String 22 | def header(key) 23 | base.email.header[key].value 24 | end 25 | 26 | # Returns the header keys as an array of strings. 27 | # 28 | # @return [String] 29 | def headers 30 | base.email.header.fields.map(&:name) 31 | end 32 | 33 | # Corrects the inspect string 34 | # 35 | # @return [String] 36 | def inspect 37 | "<#{self.class}>" 38 | end 39 | 40 | # Save a snapshot of the page. 41 | # 42 | # @param [String] path The path to where it should be saved [optional] 43 | # 44 | def save_page(path = nil) 45 | path ||= "capybara-email-#{Time.new.strftime('%Y%m%d%H%M%S')}#{rand(10**10)}.html" 46 | path = File.expand_path(path, Capybara.save_path) if Capybara.save_path 47 | 48 | FileUtils.mkdir_p(File.dirname(path)) 49 | 50 | File.write(path, body) 51 | path 52 | end 53 | 54 | # Save a snapshot of the page and open it in a browser for inspection 55 | # 56 | # @param [String] path The path to where it should be saved [optional] 57 | # 58 | def save_and_open(file_name = nil) 59 | require 'launchy' 60 | Launchy.open(save_page(file_name)) # rubocop:disable Lint/Debugger 61 | rescue LoadError 62 | warn 'Please install the launchy gem to open page with save_and_open_page' 63 | end 64 | 65 | private 66 | 67 | # Tries to send to `base` first. If an NotImplementedError is hit because 68 | # some finders/matchers/etc. aren't implemented, fall back to treating 69 | # the message body as a Capybara::Node::Simple 70 | def method_missing(method_name, *args) 71 | base.send(method_name, *args) 72 | rescue NotImplementedError 73 | body_as_simple_node.send(method_name, *args) 74 | end 75 | 76 | def respond_to_missing?(method_name, include_private = false) 77 | base.respond_to?(method_name, include_private) || super 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/capybara/email/driver.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Capybara 4 | module Email 5 | class Driver < Capybara::Driver::Base 6 | attr_reader :email 7 | 8 | def initialize(email) 9 | @email = email 10 | super() 11 | end 12 | 13 | def follow(url) 14 | url = URI.parse(url) 15 | host = "#{url.scheme}://#{url.host}" 16 | host += ":#{url.port}" unless url.port == url.default_port 17 | host_with_path = File.join(host, url.path) 18 | 19 | Capybara 20 | .current_session 21 | .visit([host_with_path, url.query].compact.join('?')) 22 | end 23 | 24 | def body 25 | dom.to_xml 26 | end 27 | 28 | # Nokogiri object for traversing content 29 | # 30 | # @return Nokogiri::HTML::Document 31 | def dom 32 | @dom ||= Nokogiri::HTML(source) 33 | end 34 | 35 | # Find elements based on given xpath 36 | # 37 | # @param [xpath string] 38 | # 39 | # @return [Array] 40 | def find(selector) 41 | dom.xpath(selector).map { |node| Capybara::Email::Node.new(self, node) } 42 | end 43 | 44 | alias find_xpath find 45 | 46 | def find_css(selector) 47 | dom.css(selector).map { |node| Capybara::Email::Node.new(self, node) } 48 | end 49 | 50 | # String version of email HTML source 51 | # 52 | # @return String 53 | def source 54 | if email.mime_type == 'text/plain' 55 | convert_to_html(raw) 56 | else 57 | raw 58 | end 59 | end 60 | 61 | # Plain text email contents 62 | # 63 | # @return String 64 | def raw 65 | if email.mime_type =~ %r{\Amultipart/(alternative|related|mixed)\Z} 66 | if email.html_part 67 | return email.html_part.body.to_s 68 | elsif email.text_part 69 | return email.text_part.body.to_s 70 | end 71 | end 72 | 73 | email.body.to_s 74 | end 75 | 76 | private 77 | 78 | def method_missing(method_name, *args) 79 | if email.respond_to?(method_name) 80 | if args.empty? 81 | email.send(method_name) 82 | else 83 | email.send(method_name, args) 84 | end 85 | else 86 | super 87 | end 88 | end 89 | 90 | def respond_to_missing?(method_name, include_private = false) 91 | email.respond_to?(method_name, include_private || super) 92 | end 93 | 94 | def convert_to_html(text) 95 | "#{convert_links(text)}" 96 | end 97 | 98 | def convert_links(text) 99 | text.gsub(%r{(https?://\S+)}, %q(\1)) 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /spec/capybara/node/email_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Capybara::Node::Email do 6 | let(:message) { Mail::Message.new } 7 | let(:email) { described_class.new(nil, Capybara::Email::Driver.new(message)) } 8 | 9 | describe '#body' do 10 | context 'with HTML' do 11 | before do 12 | message.content_type = 'text/html' 13 | message.body = 'example' 14 | end 15 | 16 | it 'delegates to the base' do 17 | expect(email.body).to eq 'example' 18 | end 19 | end 20 | 21 | context 'with plaintext' do 22 | before do 23 | message.content_type = 'text/plain' 24 | message.body = 'http://example.com' 25 | end 26 | 27 | it 'delegates to the base' do 28 | expect(email.body).to eq 'http://example.com' 29 | end 30 | end 31 | end 32 | 33 | describe '#subject' do 34 | before do 35 | message.subject = 'Test subject' 36 | end 37 | 38 | it 'delegates to the base' do 39 | expect(email.subject).to eq 'Test subject' 40 | end 41 | 42 | it 'responds to :subject' do 43 | expect(email).to respond_to(:subject) 44 | end 45 | end 46 | 47 | describe '#to' do 48 | before do 49 | message.to = 'test@example.com' 50 | end 51 | 52 | it 'delegates to the base' do 53 | expect(email.to).to include 'test@example.com' 54 | end 55 | 56 | it 'responds to :to' do 57 | expect(email).to respond_to(:to) 58 | end 59 | end 60 | 61 | describe '#reply_to' do 62 | before do 63 | message.reply_to = 'test@example.com' 64 | end 65 | 66 | it 'delegates to the base' do 67 | expect(email.reply_to).to include 'test@example.com' 68 | end 69 | 70 | it 'responds to :reply_to' do 71 | expect(email).to respond_to(:reply_to) 72 | end 73 | end 74 | 75 | describe '#from' do 76 | before do 77 | message.from = 'test@example.com' 78 | end 79 | 80 | it 'delegates to the base' do 81 | expect(email.from).to include 'test@example.com' 82 | end 83 | 84 | it 'responds to :from' do 85 | expect(email).to respond_to(:from) 86 | end 87 | end 88 | 89 | describe '#header' do 90 | before do 91 | message['header-key'] = 'header_value' 92 | end 93 | 94 | it 'delegates to the base' do 95 | expect(email.header('header-key')).to eq 'header_value' 96 | end 97 | end 98 | 99 | describe '#headers' do 100 | before do 101 | message['first-key'] = 'first_value' 102 | message['second-key'] = 'second_value' 103 | end 104 | 105 | it 'delegates to the base' do 106 | expect(email.headers) 107 | .to include('first-key') 108 | .and include('second-key') 109 | end 110 | end 111 | 112 | describe '#inspect' do 113 | it 'corrects class name' do 114 | expect(email.inspect).to eq '' 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at brian@dockyard.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CapybaraEmail # 2 | 3 | [![Build Status](https://travis-ci.org/DavyJonesLocker/capybara-email.svg?branch=master)](https://travis-ci.org/DavyJonesLocker/capybara-email) 4 | [![Code Climate](https://d3s6mut3hikguw.cloudfront.net/github/dockyard/capybara-email.svg)](https://codeclimate.com/github/dockyard/capybara-email) 5 | 6 | Easily test [ActionMailer](https://github.com/rails/rails/tree/master/actionmailer) and [Mail](https://github.com/mikel/mail) messages in your Capybara integration tests 7 | 8 | ## Installation ## 9 | 10 | Add this line to your application's Gemfile: 11 | 12 | ```ruby 13 | gem 'capybara-email' 14 | ``` 15 | 16 | And then execute: 17 | 18 | $ bundle 19 | 20 | Or install it yourself as: 21 | 22 | $ gem install capybara-email 23 | 24 | ## Usage ## 25 | 26 | ### RSpec ### 27 | 28 | In your `spec_helper.rb` require `capybara/email/rspec`. 29 | 30 | ```ruby 31 | require 'capybara/email/rspec' 32 | ``` 33 | 34 | Example: 35 | 36 | ```ruby 37 | feature 'Emailer' do 38 | background do 39 | # will clear the message queue 40 | clear_emails 41 | visit email_trigger_path 42 | # Will find an email sent to test@example.com 43 | # and set `current_email` 44 | open_email('test@example.com') 45 | end 46 | 47 | scenario 'following a link' do 48 | current_email.click_link 'your profile' 49 | expect(page).to have_content 'Profile page' 50 | end 51 | 52 | scenario 'testing for content' do 53 | expect(current_email).to have_content 'Hello Joe!' 54 | end 55 | 56 | scenario 'testing for attachments' do 57 | expect(current_email.attachments.first.filename).to eq 'filename.csv' 58 | end 59 | 60 | scenario 'testing for a custom header' do 61 | expect(current_email.headers).to include 'header-key' 62 | end 63 | 64 | scenario 'testing for a custom header value' do 65 | expect(current_email.header('header-key')).to eq 'header_value' 66 | end 67 | 68 | scenario 'view the email body in your browser' do 69 | # the `launchy` gem is required 70 | current_email.save_and_open 71 | end 72 | end 73 | ``` 74 | 75 | ### Cucumber ### 76 | Require `capybara/email` in your `features/support/env.rb` 77 | 78 | require 'capybara/email' 79 | 80 | Once you have required `capybara-email`, gaining access to usable methods 81 | is easy as adding this module to your Cucumber `World`: 82 | 83 | World(Capybara::Email::DSL) 84 | 85 | I recommend adding this to a support file such as `features/support/capybara_email.rb` 86 | 87 | ```ruby 88 | require 'capybara/email' 89 | World(Capybara::Email::DSL) 90 | ``` 91 | 92 | Example: 93 | 94 | ```ruby 95 | Scenario: Email is sent to winning user 96 | Given "me@example.com" is playing a game 97 | When that user picks a winning piece 98 | Then "me@example.com" receives an email with "You've Won!" as the subject 99 | 100 | Then /^"([^"]*)" receives an email with "([^"]*)" as the subject$/ do |email_address, subject| 101 | open_email(email_address) 102 | expect(current_email.subject).to eq subject 103 | end 104 | ``` 105 | 106 | ### Test::Unit ### 107 | 108 | Require `capybara/email` at the top of `test/test_helper.rb` 109 | 110 | ```ruby 111 | require 'capybara/email' 112 | ``` 113 | 114 | Include `Capybara::Email::DSL` in your test class 115 | 116 | ```ruby 117 | class ActionDispatch::IntegrationTest 118 | include Capybara::Email::DSL 119 | end 120 | ``` 121 | 122 | Example: 123 | 124 | ```ruby 125 | class EmailTriggerControllerTest < ActionDispatch::IntegrationTest 126 | def setup 127 | # will clear the message queue 128 | clear_emails 129 | visit email_trigger_path 130 | 131 | # Will find an email sent to `test@example.com` 132 | # and set `current_email` 133 | open_email('test@example.com') 134 | end 135 | 136 | test 'testing any email is sent' do 137 | expect(all_emails).not_to be_empty 138 | end 139 | 140 | test 'following a link' do 141 | current_email.click_link 'your profile' 142 | expect(page).to have_content 'Profile page' 143 | end 144 | 145 | test 'testing for content' do 146 | expect(current_email).to have_content 'Hello Joe!' 147 | end 148 | 149 | test 'testing for a custom header' do 150 | expect(current_email.headers).to include 'header-key' 151 | end 152 | 153 | test 'testing for a custom header value' do 154 | expect(current_email.header('header-key')).to eq 'header_value' 155 | end 156 | 157 | test 'view the email body in your browser' do 158 | # the `launchy` gem is required 159 | current_email.save_and_open 160 | end 161 | end 162 | ``` 163 | 164 | ### CurrentEmail API ### 165 | 166 | The `current_email` method will delegate all necessary method calls to 167 | `Mail::Message`. So if you need to access the subject of an email: 168 | 169 | ```ruby 170 | current_email.subject 171 | ``` 172 | 173 | Check out API for the `mail` gem for details on what methods are 174 | available. 175 | 176 | ## Setting your test host 177 | When testing, it's common to want to open an email and click through to your 178 | application. To do this, you'll probably need to update your test 179 | environment, as well as Capybara's configuration. 180 | 181 | By default, Capybara's `app_host` is set to 182 | `http://example.com.` You should update this so that it points to the 183 | same host as your test environment. In our example, we'll update both to 184 | `http://localhost:3001`: 185 | 186 | ```ruby 187 | # tests/test_helper.rb 188 | ActionDispatch::IntegrationTest do 189 | Capybara.server_port = 3001 190 | Capybara.app_host = 'http://localhost:3001' 191 | end 192 | 193 | # config/environments/test.rb 194 | config.action_mailer.default_url_options = { host: 'localhost', 195 | port: 3001 } 196 | ``` 197 | 198 | ## Sending Emails with JavaScript ## 199 | Sending emails asynchronously will cause `#open_email` to not open the 200 | correct email or not find any email at all depending on the state of the 201 | email queue. We recommend forcing a sleep prior to trying to read any 202 | email after an asynchronous event: 203 | 204 | ```ruby 205 | click_link 'Send email' 206 | sleep 0.1 207 | open_email 'test@example.com' 208 | ``` 209 | 210 | ## Authors ## 211 | 212 | [Brian Cardarella](http://twitter.com/bcardarella) 213 | 214 | [We are very thankful for the many contributors](https://github.com/dockyard/capybara-email/graphs/contributors) 215 | 216 | ## Versioning ## 217 | 218 | This gem follows [Semantic Versioning](http://semver.org) 219 | 220 | ## Want to help? ## 221 | 222 | Stable branches are created based upon each minor version. Please make 223 | pull requests to specific branches rather than master. 224 | 225 | Please make sure you include tests! 226 | 227 | Don't use tabs to indent, two spaces are the standard. 228 | 229 | ## Legal ## 230 | 231 | [DockYard](http://dockyard.com), Inc. © 2014 232 | 233 | [@dockyard](http://twitter.com/dockyard) 234 | 235 | [Licensed under the MIT license](http://www.opensource.org/licenses/mit-license.php) 236 | -------------------------------------------------------------------------------- /spec/email/driver_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | class TestApp 6 | def self.call(_env) 7 | [200, { 'Content-Type' => 'text/plain' }, ['Hello world!']] 8 | end 9 | end 10 | 11 | feature 'Integration test' do 12 | background do 13 | clear_email 14 | Capybara.app = TestApp 15 | end 16 | 17 | scenario 'html email' do 18 | email = deliver(html_email) 19 | 20 | open_email('test@example.com') 21 | current_email.click_link 'example' 22 | expect(page).to have_content 'Hello world!' 23 | expect(current_email).to have_content 'This is only a html test' 24 | expect(current_email).to have_css 'a' 25 | 26 | expect(all_emails.first).to eq email 27 | 28 | clear_emails 29 | expect(all_emails).to be_empty 30 | end 31 | 32 | scenario 'html email follows links' do 33 | deliver(html_email) 34 | open_email('test@example.com') 35 | 36 | current_email.click_link 'example' 37 | expect(page.current_url).to eq('http://example.com/') 38 | 39 | current_email.click_link 'another example' 40 | expect(page.current_url).to eq('http://example.com:1234/') 41 | 42 | current_email.click_link 'yet another example' 43 | expect(page.current_url).to eq('http://example.com:1234/some/path?foo=bar') 44 | end 45 | 46 | scenario 'html email follows links via click_on' do 47 | deliver(html_email) 48 | open_email('test@example.com') 49 | 50 | current_email.click_on 'example' 51 | expect(page.current_url).to eq('http://example.com/') 52 | end 53 | 54 | scenario 'plain text email' do 55 | email = deliver(plain_email) 56 | 57 | open_email('test@example.com') 58 | current_email.click_link 'http://example.com' 59 | expect(page).to have_content 'Hello world!' 60 | expect(current_email).to have_content 'This is only a plain test.' 61 | 62 | expect(all_emails.first).to eq email 63 | 64 | clear_emails 65 | expect(all_emails).to be_empty 66 | end 67 | 68 | # should read html_part 69 | scenario 'multipart email' do 70 | email = deliver(multipart_email) 71 | 72 | open_email('test@example.com') 73 | current_email.click_link 'example' 74 | expect(page).to have_content 'Hello world!' 75 | expect(current_email).to have_content 'This is only a html test' 76 | 77 | expect(all_emails.first).to eq email 78 | 79 | clear_emails 80 | expect(all_emails).to be_empty 81 | end 82 | 83 | it 'delegates to base' do 84 | deliver(plain_email) 85 | open_email('test@example.com') 86 | expect(current_email.subject).to eq 'Test Email' 87 | end 88 | 89 | # should read html_part 90 | scenario 'multipart/related email' do 91 | email = deliver(multipart_related_email) 92 | 93 | open_email('test@example.com') 94 | current_email.click_link 'example' 95 | expect(page).to have_content 'Hello world!' 96 | expect(current_email).to have_content 'This is only a html test' 97 | 98 | expect(all_emails.first).to eq email 99 | 100 | clear_emails 101 | expect(all_emails).to be_empty 102 | end 103 | 104 | # should read html_part 105 | scenario 'multipart/mixed email' do 106 | email = deliver(multipart_mixed_email) 107 | 108 | open_email('test@example.com') 109 | current_email.click_link 'example' 110 | expect(page).to have_content 'Hello world!' 111 | expect(current_email).to have_content 'This is only a html test' 112 | 113 | expect(all_emails.first).to eq email 114 | 115 | clear_emails 116 | expect(all_emails).to be_empty 117 | end 118 | 119 | scenario 'email content matchers' do 120 | deliver(multipart_email) 121 | open_email('test@example.com') 122 | expect(current_email).to have_link('another example', href: 'http://example.com:1234') 123 | end 124 | 125 | scenario 'via ActionMailer' do 126 | email = deliver(plain_email) 127 | 128 | expect(all_emails.first).to eq email 129 | 130 | clear_emails 131 | expect(all_emails).to be_empty 132 | end 133 | 134 | scenario 'via Mail' do 135 | email = plain_email.deliver! 136 | 137 | expect(all_emails.first).to eq email 138 | 139 | clear_emails 140 | expect(all_emails).to be_empty 141 | end 142 | 143 | scenario 'multiple emails' do 144 | deliver(plain_email) 145 | deliver(Mail::Message.new(to: 'test@example.com', body: 'New Message', context: 'text/plain')) 146 | open_email('test@example.com') 147 | expect(current_email.body).to eq 'New Message' 148 | end 149 | 150 | scenario "cc'd" do 151 | deliver(Mail::Message.new(cc: 'test@example.com', body: 'New Message', context: 'text/plain')) 152 | open_email('test@example.com') 153 | expect(current_email.body).to eq 'New Message' 154 | end 155 | 156 | scenario "bcc'd" do 157 | deliver(Mail::Message.new(bcc: 'test@example.com', body: 'New Message', context: 'text/plain')) 158 | open_email('test@example.com') 159 | expect(current_email.body).to eq 'New Message' 160 | end 161 | end 162 | 163 | def deliver(email) 164 | ActionMailer::Base.deliveries << email 165 | email 166 | end 167 | 168 | def html_email 169 | Mail::Message.new( 170 | body: <<~HTML, 171 | 172 | 173 |

174 | This is only a html test. 175 | example 176 | another example 177 | yet another example 178 |

179 | 180 | 181 | HTML 182 | content_type: 'text/html', 183 | to: 'test@example.com', 184 | ) 185 | end 186 | 187 | def plain_email 188 | Mail::Message.new( 189 | body: <<-PLAIN, 190 | This is only a plain test. 191 | http://example.com 192 | PLAIN 193 | content_type: 'text/plain', 194 | to: 'test@example.com', 195 | from: 'sender@example.com', 196 | subject: 'Test Email', 197 | ) 198 | end 199 | 200 | def multipart_email 201 | Mail::Message.new do 202 | to 'test@example.com' 203 | text_part do 204 | body plain_email.body.encoded 205 | end 206 | html_part do 207 | content_type 'text/html; charset=UTF-8' 208 | body html_email.body.encoded 209 | end 210 | end 211 | end 212 | 213 | def multipart_related_email 214 | Mail::Message.new do 215 | to 'test@example.com' 216 | text_part do 217 | body plain_email.body.encoded 218 | end 219 | html_part do 220 | content_type 'text/html; charset=UTF-8' 221 | body html_email.body.encoded 222 | end 223 | content_type 'multipart/related; charset=UTF-8' 224 | end 225 | end 226 | 227 | def multipart_mixed_email 228 | Mail::Message.new do 229 | to 'test@example.com' 230 | text_part do 231 | body plain_email.body.encoded 232 | end 233 | html_part do 234 | content_type 'text/html; charset=UTF-8' 235 | body html_email.body.encoded 236 | end 237 | content_type 'multipart/mixed; charset=UTF-8' 238 | end 239 | end 240 | --------------------------------------------------------------------------------