├── .gitignore ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── circle.yml ├── lib ├── mail_interceptor.rb └── mail_interceptor │ ├── engine.rb │ ├── initializers │ └── zerobounce.rb │ ├── railtie.rb │ └── version.rb ├── mail_interceptor.gemspec └── test └── mail_interceptor_test.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.bundle 11 | *.so 12 | *.o 13 | *.a 14 | mkmf.log 15 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'rails', '7.0.3.1' 6 | 7 | gem 'zerobounce', '0.3.1' 8 | 9 | # Specify your gem's dependencies in mail_interceptor.gemspec 10 | gemspec 11 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Neeraj Singh 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mail Interceptor 2 | 3 | - [Mail Interceptor](#mail-interceptor) 4 | - [About](#about) 5 | - [Usage](#usage) 6 | - [only_intercept](#only_intercept) 7 | - [deliver_emails_to](#deliver_emails_to) 8 | - [forward_emails_to](#forward_emails_to) 9 | - [ignore_bcc and ignore_cc](#ignore_bcc-and-ignore_cc) 10 | - [Custom Environment](#custom-environment) 11 | - [Prefixing email with subject](#prefixing-email-with-subject) 12 | - [Brought to you by](#brought-to-you-by) 13 | 14 | ## About 15 | 16 | This gem intercepts and forwards email to a forwarding address in 17 | a non-production environment. This is to ensure that in staging or 18 | in development by mistake we do not deliver emails to the real people. 19 | However we need to test emails time to time. 20 | 21 | Refer to https://github.com/bigbinary/wheel/blob/master/config/initializers/mail_interceptor.rb 22 | and you will notice that if an email ends with `deliver@bigbinary.com` then that emaill will be delivered. 23 | 24 | So if Neeraj wants to test how the real email looks then he can use email `neeraj+deliver@bigbinary.com` and that email will be delivered. 25 | As long as an email ends with `deliver@bigbinary.com` then that email will not be intercepted. 26 | 27 | If client wants to test something and the client expects an email to be delivered then we need to add client's email here. 28 | Say the client's email is `michael@timbaktu.com`. 29 | Change that line to deliver_emails_to: `["deliver@bigbinary.com", "timbaktu.com"]`. 30 | Now all emails ending with `timbaktu.com` would be delivered. 31 | If you want only Michael should get email and other emails ending with "timbaktu.com" to be intercepted then change that line to deliver_emails_to: `["deliver@bigbinary.com", "michael@timbaktu.com"]`. 32 | 33 | ## Usage 34 | 35 | ```ruby 36 | # There is no need to include this gem for production or for test environment 37 | gem 'mail_interceptor', group: [:development, :staging] 38 | ``` 39 | 40 | ```ruby 41 | # config/initializers/mail_interceptor.rb 42 | 43 | options = { forward_emails_to: 'intercepted_emails@domain.com', 44 | deliver_emails_to: ["@wheel.com"] } 45 | 46 | unless (Rails.env.test? || Rails.env.production?) 47 | interceptor = MailInterceptor::Interceptor.new(options) 48 | ActionMailer::Base.register_interceptor(interceptor) 49 | end 50 | ``` 51 | 52 | Do not use this feature in test mode so that in tests 53 | you can test against provided recipients of the email. 54 | 55 | ## only_intercept 56 | 57 | Passing `only_intercept` is optional. If `only_intercept` is passed then only emails 58 | having the pattern mentioned in `only_intercept` will be intercepted. Rest of the emails 59 | will be delivered. 60 | 61 | Let's say you want to only intercept emails ending with `@bigbinary.com` and forward the email. 62 | Here's how it can be accomplished. 63 | 64 | ```ruby 65 | MailInterceptor::Interceptor.new({ forward_emails_to: 'intercepted_emails@domain.com', 66 | only_intercept: ["@bigbinary.com"] }) 67 | ``` 68 | 69 | This will only intercept emails ending with `@bigbinary.com` and forward the emails. Every other 70 | email will be delivered. 71 | 72 | Suppose you want to intercept only some emails and not deliver them. You can do that by only 73 | passing the `only_intercept` option like so: 74 | 75 | ```ruby 76 | MailInterceptor::Interceptor.new({ only_intercept: ["@bigbinary.com"] }) 77 | ``` 78 | 79 | This will intercept emails ending with `@bigbinary` and not deliver them. 80 | 81 | ## deliver_emails_to 82 | 83 | Passing `deliver_emails_to` is optional. If no `deliver_emails_to` 84 | is passed then all emails will be intercepted and forwarded in 85 | non-production environment. 86 | 87 | Let's say you want to actually deliver all emails having the pattern 88 | "@BigBinary.com". Here is how it can be accomplished. 89 | 90 | ```ruby 91 | MailInterceptor::Interceptor.new({ forward_emails_to: 'intercepted_emails@domain.com', 92 | deliver_emails_to: ["@bigbinary.com"] }) 93 | ``` 94 | 95 | If you want the emails to be delivered only if the email address is 96 | `qa@bigbinary.com` then that can be done too. 97 | 98 | ```ruby 99 | MailInterceptor::Interceptor.new({ forward_emails_to: 'intercepted_emails@domain.com', 100 | deliver_emails_to: ["qa@bigbinary.com"] }) 101 | ``` 102 | 103 | Now only `qa@bigbinary.com` will get its emails delivered and all other emails 104 | will be intercepted and forwarded. 105 | 106 | The regular expression is matched without case sensitive. So you can mix lowercase 107 | and uppercase and it won't matter. 108 | 109 | ## forward_emails_to 110 | 111 | Passing `forward_emails_to` is optional. If no `forward_emails_to` 112 | is passed then all emails will be intercepted and 113 | only emails matching with `deliver_emails_to` will be delivered. 114 | 115 | Blank options can be provided to intercept and not send any emails. 116 | 117 | ```ruby 118 | MailInterceptor::Interceptor.new({}) 119 | ``` 120 | 121 | It can take a single email or an array of emails. 122 | 123 | ```ruby 124 | MailInterceptor::Interceptor.new({ forward_emails_to: 'intercepted_emails@bigbinary.com' }) 125 | ``` 126 | 127 | It can also take an array of emails in which case emails are forwarded to each of those emails in the array. 128 | 129 | ```ruby 130 | MailInterceptor::Interceptor.new({ forward_emails_to: ['intercepted_emails@bigbinary.com', 131 | 'qa@bigbinary.com' }) 132 | ``` 133 | 134 | ## ignore_bcc and ignore_cc 135 | 136 | By default bcc and cc are ignored. 137 | You can pass `:ignore_bcc` or `:ignore_cc` options as `false`, 138 | if you don't want to ignore bcc or cc. 139 | 140 | ## Custom Environment 141 | 142 | By default all emails sent in non production environment are 143 | intercepted. However you can control this behavior by passing `env` as 144 | the key. It accepts any ruby objects which responds to `intercept?` 145 | method. If the result of that method is `true` then emails are 146 | intercepted otherwise emails are not intercepted. 147 | 148 | Below is an example of how to pass a custom ruby object as value for 149 | `env` key. 150 | 151 | Besides method `intercept?` method `name` is needed if you have provided 152 | `subject_prefix`. This name will be appended to the `subject_prefix` to 153 | produce something like `[WHEEL STAGING] Forgot password`. In this case 154 | `STAGING` came form `name`. 155 | 156 | ```ruby 157 | class MyEnv 158 | def name 159 | ENV["ENVIRONMENT_NAME"] 160 | end 161 | 162 | def intercept? 163 | ENV["INTERCEPT_MAIL"] == '1' 164 | end 165 | end 166 | 167 | MailInterceptor::Interceptor.new({ env: MyEnv.new, 168 | forward_emails_to: ['intercepted_emails@bigbinary.com', 169 | 'qa@bigbinary.com' }) 170 | ``` 171 | 172 | ## Prefixing email with subject 173 | 174 | If you are looking for automatically prefix all delivered emails with the application name and Rails environment 175 | then we recommend using [email_prefixer gem](https://github.com/wireframe/email_prefixer) . 176 | 177 | ## Brought to you by 178 | 179 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | 5 | require 'rake/testtask' 6 | require 'rubygems/package_task' 7 | 8 | task default: :test 9 | 10 | Rake::TestTask.new('test') do |t| 11 | t.pattern = 'test/**/*_test.rb' 12 | t.warning = true 13 | t.verbose = true 14 | end 15 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | jobs: 4 | build: 5 | docker: 6 | - image: cimg/ruby:3.2.2 7 | steps: 8 | - checkout 9 | 10 | - run: 11 | name: Build gem 12 | command: gem build mail_interceptor 13 | 14 | - run: bundle install 15 | 16 | - run: 17 | name: Run tests 18 | command: bundle exec rake test 19 | -------------------------------------------------------------------------------- /lib/mail_interceptor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support" 4 | require 'active_support/core_ext/object/blank' 5 | require 'active_support/core_ext/array' 6 | require 'mail_interceptor/version' 7 | 8 | module MailInterceptor 9 | mattr_accessor :enable_zerobounce_validation 10 | @@enable_zerobounce_validation = false 11 | 12 | def self.configure 13 | yield self 14 | end 15 | 16 | class Interceptor 17 | attr_accessor :deliver_emails_to, :forward_emails_to, :intercept_emails, :env, :recipients, :ignore_cc, :ignore_bcc 18 | 19 | def initialize(options = {}) 20 | @deliver_emails_to = Array.wrap options[:deliver_emails_to] 21 | @forward_emails_to = Array.wrap options[:forward_emails_to] 22 | @intercept_emails = options.fetch :only_intercept, [] 23 | @ignore_cc = options.fetch :ignore_cc, false 24 | @ignore_bcc = options.fetch :ignore_bcc, false 25 | @env = options.fetch :env, InterceptorEnv.new 26 | @recipients = [] 27 | end 28 | 29 | def delivering_email(message) 30 | @recipients = message.to 31 | to_emails_list = normalize_recipients 32 | 33 | to_emails_list = to_emails_list.filter { |email| zerobounce_validate_email(email) } if zerobounce_enabled? 34 | 35 | message.perform_deliveries = to_emails_list.present? 36 | message.to = to_emails_list 37 | message.cc = [] if ignore_cc 38 | message.bcc = [] if ignore_bcc 39 | end 40 | 41 | private 42 | 43 | def zerobounce_enabled? 44 | MailInterceptor.enable_zerobounce_validation && Zerobounce.configuration.apikey.present? 45 | end 46 | 47 | def normalize_recipients 48 | return Array.wrap(recipients) unless env.intercept? 49 | 50 | normalized_recipients = filter_by_intercept_emails 51 | normalized_recipients << filter_by_deliver_emails_to 52 | normalized_recipients.flatten.uniq.reject(&:blank?) 53 | end 54 | 55 | def filter_by_intercept_emails 56 | if intercept_emails.present? 57 | recipients.map do |recipient| 58 | if intercept_emails.find { |regex| Regexp.new(regex, Regexp::IGNORECASE).match(recipient) } 59 | forward_emails_to 60 | else 61 | recipient 62 | end 63 | end 64 | else 65 | [] 66 | end 67 | end 68 | 69 | def filter_by_deliver_emails_to 70 | return forward_emails_to if deliver_emails_to.empty? && intercept_emails.empty? 71 | 72 | if intercept_emails.empty? 73 | recipients.map do |recipient| 74 | if deliver_emails_to.find { |regex| Regexp.new(regex, Regexp::IGNORECASE).match(recipient) } 75 | recipient 76 | else 77 | forward_emails_to 78 | end 79 | end 80 | else 81 | [] 82 | end 83 | end 84 | 85 | def zerobounce_validate_email(email) 86 | return true if email.end_with? "privaterelay.appleid.com" 87 | is_email_valid = Zerobounce.validate(email: email).valid? 88 | print "Zerobounce validation for #{email} is #{is_email_valid ? 'valid' : 'invalid'}\n" 89 | is_email_valid 90 | end 91 | end 92 | 93 | class InterceptorEnv 94 | def name 95 | Rails.env.upcase 96 | end 97 | 98 | def intercept? 99 | !Rails.env.production? 100 | end 101 | end 102 | 103 | require 'mail_interceptor/railtie' if defined?(Rails) && Rails::VERSION::MAJOR >= 3 104 | end 105 | -------------------------------------------------------------------------------- /lib/mail_interceptor/engine.rb: -------------------------------------------------------------------------------- 1 | module MailInterceptor 2 | class Engine < Rails::Engine 3 | end 4 | end -------------------------------------------------------------------------------- /lib/mail_interceptor/initializers/zerobounce.rb: -------------------------------------------------------------------------------- 1 | require "zerobounce" 2 | 3 | Zerobounce.configure do |config| 4 | config.apikey = ENV['ZEROBOUNCE_API_KEY'] 5 | config.valid_statuses = [:valid, :catch_all, :unknown] 6 | end 7 | -------------------------------------------------------------------------------- /lib/mail_interceptor/railtie.rb: -------------------------------------------------------------------------------- 1 | require "rails" 2 | 3 | module MailInterceptor 4 | class Railtie < Rails::Railtie 5 | initializer "configure_zerobounce" do 6 | require "mail_interceptor/initializers/zerobounce.rb" 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/mail_interceptor/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MailInterceptor 4 | VERSION = '0.1.0' 5 | end 6 | -------------------------------------------------------------------------------- /mail_interceptor.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'mail_interceptor/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'mail_interceptor' 9 | spec.version = MailInterceptor::VERSION 10 | spec.authors = ['Neeraj Singh'] 11 | spec.email = ['neeraj@BigBinary.com'] 12 | spec.summary = 'Intercepts and forwards emails in non production environment' 13 | spec.homepage = 'https://github.com/bigbinary/mail_interceptor' 14 | spec.license = 'MIT' 15 | 16 | spec.files = `git ls-files -z`.split("\x0") 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ['lib'] 20 | 21 | spec.required_ruby_version = '>= 3.0' 22 | 23 | spec.add_runtime_dependency 'activesupport', '>= 7' 24 | spec.add_runtime_dependency 'zerobounce', '~> 0.3.1' 25 | spec.add_development_dependency 'bundler', '~> 2' 26 | spec.add_development_dependency 'minitest', '~> 5' 27 | spec.add_development_dependency 'mocha', '~> 1' 28 | spec.add_development_dependency 'rake', '~> 13' 29 | end 30 | -------------------------------------------------------------------------------- /test/mail_interceptor_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubygems' 4 | require 'bundler/setup' 5 | require 'minitest/autorun' 6 | require 'ostruct' 7 | require_relative './../lib/mail_interceptor' 8 | 9 | class MailInterceptorTest < Minitest::Test 10 | def setup 11 | @message = OpenStruct.new 12 | end 13 | 14 | def test_normalized_deliver_emails_to 15 | @interceptor = ::MailInterceptor::Interceptor.new env: env, 16 | forward_emails_to: 'test@example.com' 17 | assert_equal [], @interceptor.deliver_emails_to 18 | 19 | @interceptor = ::MailInterceptor::Interceptor.new env: env, 20 | forward_emails_to: 'test@example.com', 21 | deliver_emails_to: '@wheel.com' 22 | assert_equal ['@wheel.com'], @interceptor.deliver_emails_to 23 | 24 | @interceptor = ::MailInterceptor::Interceptor.new env: env, 25 | forward_emails_to: 'test@example.com', 26 | deliver_emails_to: ['@wheel.com', '@pump.com'] 27 | assert_equal ['@wheel.com', '@pump.com'], @interceptor.deliver_emails_to 28 | end 29 | 30 | def test_invocation_of_regular_expression 31 | interceptor = ::MailInterceptor::Interceptor.new env: env, 32 | forward_emails_to: 'test@example.com', 33 | deliver_emails_to: ['@wheel.com', '@pump.com', 'john@gmail.com'] 34 | @message.to = [ 35 | 'a@wheel.com', 'b@wheel.com', 'c@pump.com', 'd@club.com', 'e@gmail.com', 'john@gmail.com', 'sam@gmail.com' 36 | ] 37 | interceptor.delivering_email @message 38 | assert_equal ['a@wheel.com', 'b@wheel.com', 'c@pump.com', 'test@example.com', 'john@gmail.com'], @message.to 39 | end 40 | 41 | def test_that_emails_are_not_sent_to_intercept_emails 42 | interceptor = ::MailInterceptor::Interceptor.new env: prod_env, 43 | only_intercept: ['@wheel.com', '@pump.com'] 44 | assert_equal ['@wheel.com', '@pump.com'], interceptor.intercept_emails 45 | @message.to = [ 46 | 'a@wheel.com', 'b@wheel.com', 'c@pump.com', 'd@club.com', 'e@gmail.com', 'john@gmail.com', 'sam@gmail.com' 47 | ] 48 | interceptor.delivering_email @message 49 | assert_equal ['d@club.com', 'e@gmail.com', 'john@gmail.com', 'sam@gmail.com'], @message.to 50 | end 51 | 52 | def test_that_only_intercept_option_takes_precedence_over_deliver_emails_to_option 53 | interceptor = ::MailInterceptor::Interceptor.new env: prod_env, 54 | only_intercept: ['@wheel.com', '@pump.com'], 55 | forward_emails_to: ['incoming@example.com'], 56 | deliver_emails_to: ['@wheel.com', '@pump.com', 'john@gmail.com'] 57 | assert_equal ['@wheel.com', '@pump.com'], interceptor.intercept_emails 58 | @message.to = [ 59 | 'a@wheel.com', 'b@wheel.com', 'c@pump.com', 'd@club.com', 'e@gmail.com', 'john@gmail.com', 'sam@gmail.com' 60 | ] 61 | interceptor.delivering_email @message 62 | assert_equal ['incoming@example.com', 'd@club.com', 'e@gmail.com', 'john@gmail.com', 'sam@gmail.com'], @message.to 63 | end 64 | 65 | def test_that_when_forward_emails_to_is_empty_then_emails_are_skipped 66 | interceptor = ::MailInterceptor::Interceptor.new env: env 67 | interceptor.delivering_email @message 68 | 69 | assert_equal false, @message.perform_deliveries 70 | 71 | interceptor = ::MailInterceptor::Interceptor.new env: env, forward_emails_to: [] 72 | interceptor.delivering_email @message 73 | 74 | assert_equal false, @message.perform_deliveries 75 | 76 | interceptor = ::MailInterceptor::Interceptor.new env: env, forward_emails_to: '' 77 | interceptor.delivering_email @message 78 | 79 | assert_equal false, @message.perform_deliveries 80 | 81 | interceptor = ::MailInterceptor::Interceptor.new env: env, forward_emails_to: [''] 82 | interceptor.delivering_email @message 83 | 84 | assert_equal false, @message.perform_deliveries 85 | end 86 | 87 | def test_ignore_bcc_and_cc 88 | interceptor = ::MailInterceptor::Interceptor.new env: env, 89 | forward_emails_to: 'test@example.com', 90 | ignore_bcc: true, 91 | ignore_cc: true 92 | @message.bcc = ['bcc@example.com'] 93 | @message.cc = ['cc@example.com'] 94 | interceptor.delivering_email @message 95 | assert_equal [], @message.bcc 96 | assert_equal [], @message.cc 97 | end 98 | 99 | def test_do_not_ignore_bcc_or_cc 100 | interceptor = ::MailInterceptor::Interceptor.new env: env, 101 | forward_emails_to: 'test@example.com', 102 | ignore_bcc: false, 103 | ignore_cc: false 104 | @message.bcc = ['bcc@example.com'] 105 | @message.cc = ['cc@example.com'] 106 | interceptor.delivering_email @message 107 | assert_equal ['bcc@example.com'], @message.bcc 108 | assert_equal ['cc@example.com'], @message.cc 109 | end 110 | 111 | private 112 | 113 | def env(environment = 'test') 114 | OpenStruct.new name: environment.upcase, 115 | intercept?: environment != 'production' 116 | end 117 | 118 | def prod_env(environment = 'production') 119 | OpenStruct.new name: environment.upcase, 120 | intercept?: true 121 | end 122 | end 123 | --------------------------------------------------------------------------------