diff --git a/.reek.yml b/.reek.yml index 7b9083b..58b070b 100644 --- a/.reek.yml +++ b/.reek.yml @@ -21,18 +21,21 @@ detectors: Attribute: exclude: - Truemail::Configuration#smtp_safe_check - - Truemail::Validate::ResolverExecutionWrapper#attempts + - Truemail::Wrapper#attempts UtilityFunction: exclude: - Truemail::Validate::Smtp::Request#compose_from - Truemail::Validator#select_validation_type - Truemail::Validate::Mx#null_mx? + - Truemail::Validate::Mx#a_record + - Truemail::Audit::Ptr#current_host_address ControlParameter: exclude: - Truemail::GenerateEmailHelper#calculate_email_size - - Truemail::Validate::Base#success + - Truemail::Worker#success + - Truemail#raise_unless FeatureEnvy: exclude: diff --git a/Gemfile.lock b/Gemfile.lock index 4d77e6e..b930cec 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - truemail (0.1.6) + truemail (0.1.7) GEM remote: https://rubygems.org/ diff --git a/README.md b/README.md index 019a97e..7849dc0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Maintainability](https://api.codeclimate.com/v1/badges/657aa241399927dcd2e2/maintainability)](https://codeclimate.com/github/rubygarage/truemail/maintainability) [![Test Coverage](https://api.codeclimate.com/v1/badges/657aa241399927dcd2e2/test_coverage)](https://codeclimate.com/github/rubygarage/truemail/test_coverage) [![Gem Version](https://badge.fury.io/rb/truemail.svg)](https://badge.fury.io/rb/truemail) [![CircleCI](https://circleci.com/gh/rubygarage/truemail/tree/master.svg?style=svg)](https://circleci.com/gh/rubygarage/truemail/tree/master) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-v1.4%20adopted-ff69b4.svg)](CODE_OF_CONDUCT.md) -The Truemail gem helps you validate emails by regex pattern, presence of domain mx-records, and real existence of email account on a current email server. +The Truemail gem helps you validate emails by regex pattern, presence of domain mx-records, and real existence of email account on a current email server. Also Truemail gem allows performing an audit of the host in which runs. ## Features @@ -137,7 +137,7 @@ Truemail.configuration #### Regex validation -Validation with regex pattern is the first validation level. By default this validation not performs strictly following RFC 5322 standart, so you can override Truemail default regex pattern if you want. +Validation with regex pattern is the first validation level. By default this validation not performs strictly following RFC 5322 standard, so you can override Truemail default regex pattern if you want. Example of usage: @@ -383,6 +383,30 @@ Truemail.validate('email@example.com') @validation_type=:smtp> ``` +### Host audit features + +Truemail gem allows performing an audit of the host in which runs. Only PTR record audit performs for today. + +#### PTR audit + +So what is a PTR record? A PTR record, or pointer record, enables someone to perform a reverse DNS lookup. This allows them to determine your domain name based on your IP address. Because generic domain names without a PTR are often associated with spammers, incoming mail servers identify email from hosts without PTR records as spam and you can't verify yours emails qualitatively. + +```ruby +Truemail.host_audit +# Everything is good +=> #> + +# Has PTR warning +=> #"ptr record does not reference to current verifier domain"}>> +``` + ### Truemail helpers #### .valid? diff --git a/lib/truemail.rb b/lib/truemail.rb index 28168cc..1f95699 100644 --- a/lib/truemail.rb +++ b/lib/truemail.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true -require 'truemail/version' require 'truemail/core' -require 'truemail/configuration' -require 'truemail/validator' module Truemail INCOMPLETE_CONFIG = 'verifier_email is required parameter' @@ -15,7 +12,7 @@ def configuration return unless block_given? configuration = Truemail::Configuration.new yield(configuration) - raise ConfigurationError, INCOMPLETE_CONFIG unless configuration.complete? + raise_unless(configuration.complete?, INCOMPLETE_CONFIG) configuration end end @@ -29,12 +26,23 @@ def reset_configuration! end def validate(email, **options) - raise ConfigurationError, NOT_CONFIGURED unless configuration + raise_unless(configuration, NOT_CONFIGURED) Truemail::Validator.new(email, **options).run end def valid?(email, **options) validate(email, **options).result.valid? end + + def host_audit + raise_unless(configuration, NOT_CONFIGURED) + Truemail::Auditor.run + end + + private + + def raise_unless(condition, message) + raise ConfigurationError, message unless condition + end end end diff --git a/lib/truemail/audit/base.rb b/lib/truemail/audit/base.rb new file mode 100644 index 0000000..994c290 --- /dev/null +++ b/lib/truemail/audit/base.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Truemail + module Audit + class Base < Truemail::Worker + private + + def add_warning(message) + result.warnings[self.class.name.split('::').last.downcase.to_sym] = message + end + end + end +end diff --git a/lib/truemail/audit/ptr.rb b/lib/truemail/audit/ptr.rb new file mode 100644 index 0000000..6f7bc95 --- /dev/null +++ b/lib/truemail/audit/ptr.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Truemail + module Audit + class Ptr < Truemail::Audit::Base + require 'ipaddr' + require 'resolv' + + NOT_FOUND = 'ptr record for current host address was not found' + NOT_REFERENCES = 'ptr record does not reference to current verifier domain' + + def run + return if ptr_records.empty? && add_warning(Truemail::Audit::Ptr::NOT_FOUND) + return if ptr_references_to_verifier_domain? + add_warning(Truemail::Audit::Ptr::NOT_REFERENCES) + end + + private + + def current_host_address + Resolv.getaddress(Socket.gethostname) + end + + def current_host_reverse_lookup + IPAddr.new(current_host_address).reverse + end + + def ptr_records + @ptr_records ||= Truemail::Wrapper.call do + Resolv::DNS.new.getresources( + current_host_reverse_lookup, Resolv::DNS::Resource::IN::PTR + ).map { |ptr_record| ptr_record.name.to_s } + end || [] + end + + def ptr_references_to_verifier_domain? + ptr_records.include?(Truemail.configuration.verifier_domain) + end + end + end +end diff --git a/lib/truemail/auditor.rb b/lib/truemail/auditor.rb new file mode 100644 index 0000000..6f9679d --- /dev/null +++ b/lib/truemail/auditor.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Truemail + class Auditor + Result = Struct.new(:warnings, keyword_init: true) do + def initialize(warnings: {}, **args) + super + end + end + + def self.run + new.run + end + + def result + @result ||= Truemail::Auditor::Result.new + end + + def run + Truemail::Audit::Ptr.check(result) + self + end + end +end diff --git a/lib/truemail/core.rb b/lib/truemail/core.rb index 23b33d2..85c08f1 100644 --- a/lib/truemail/core.rb +++ b/lib/truemail/core.rb @@ -1,6 +1,13 @@ # frozen_string_literal: true module Truemail + require 'truemail/version' + require 'truemail/configuration' + require 'truemail/worker' + require 'truemail/wrapper' + require 'truemail/auditor' + require 'truemail/validator' + class ConfigurationError < StandardError; end class ArgumentError < StandardError @@ -16,10 +23,14 @@ module RegexConstant REGEX_DOMAIN_FROM_EMAIL = /\A.+@(.+)\z/ end + module Audit + require 'truemail/audit/base' + require 'truemail/audit/ptr' + end + module Validate require 'truemail/validate/base' require 'truemail/validate/regex' - require 'truemail/validate/resolver_execution_wrapper' require 'truemail/validate/mx' require 'truemail/validate/smtp' require 'truemail/validate/smtp/response' diff --git a/lib/truemail/validate/base.rb b/lib/truemail/validate/base.rb index a009c6a..0adfb6a 100644 --- a/lib/truemail/validate/base.rb +++ b/lib/truemail/validate/base.rb @@ -2,23 +2,9 @@ module Truemail module Validate - class Base - attr_reader :result - - def self.check(result) - new(result).run - end - - def initialize(result) - @result = result - end - + class Base < Truemail::Worker private - def success(condition) - result.success = condition || false - end - def add_error(message) result.errors[self.class.name.split('::').last.downcase.to_sym] = message end diff --git a/lib/truemail/validate/mx.rb b/lib/truemail/validate/mx.rb index ca385cf..77d2d78 100644 --- a/lib/truemail/validate/mx.rb +++ b/lib/truemail/validate/mx.rb @@ -24,7 +24,7 @@ def host_extractor_methods def mx_lookup host_extractor_methods.any? do |method| - Truemail::Validate::ResolverExecutionWrapper.call { send(method) } + Truemail::Wrapper.call { send(method) } end end @@ -41,8 +41,8 @@ def domain_not_include_null_mx !mail_servers.include?(Truemail::Validate::Mx::NULL_MX_RECORD) end - def mx_records(domain) - domain_mx_records = Resolv::DNS.new.getresources(domain, Resolv::DNS::Resource::IN::MX) + def mx_records(hostname) + domain_mx_records = Resolv::DNS.new.getresources(hostname, Resolv::DNS::Resource::IN::MX) return [Truemail::Validate::Mx::NULL_MX_RECORD] if null_mx?(domain_mx_records) domain_mx_records.sort_by(&:preference).map do |mx_record| Resolv.getaddresses(mx_record.exchange.to_s) @@ -53,16 +53,24 @@ def mail_servers_found? !mail_servers.empty? end + def domain + result.domain + end + def hosts_from_mx_records? - fetch_target_hosts(mx_records(result.domain)) + fetch_target_hosts(mx_records(domain)) mail_servers_found? end + def a_record(hostname) + Resolv.getaddress(hostname) + end + def hosts_from_cname_records? - cname_records = Resolv::DNS.new.getresources(result.domain, Resolv::DNS::Resource::IN::CNAME) + cname_records = Resolv::DNS.new.getresources(domain, Resolv::DNS::Resource::IN::CNAME) return if cname_records.empty? cname_records.each do |cname_record| - host = Resolv.getaddress(cname_record.name.to_s) + host = a_record(cname_record.name.to_s) hostname = Resolv.getname(host) found_hosts = mx_records(hostname) fetch_target_hosts(found_hosts.empty? ? [host] : found_hosts) @@ -71,7 +79,7 @@ def hosts_from_cname_records? end def host_from_a_record? - fetch_target_hosts([Resolv.getaddress(result.domain)]) + fetch_target_hosts([a_record(domain)]) mail_servers_found? end end diff --git a/lib/truemail/validate/resolver_execution_wrapper.rb b/lib/truemail/validate/resolver_execution_wrapper.rb deleted file mode 100644 index 16b8d89..0000000 --- a/lib/truemail/validate/resolver_execution_wrapper.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -module Truemail - module Validate - class ResolverExecutionWrapper - attr_accessor :attempts - - def self.call(&block) - new.call(&block) - end - - def initialize - @attempts = Truemail.configuration.connection_attempts - end - - def call(&block) - Timeout.timeout(Truemail.configuration.connection_timeout, &block) - rescue Resolv::ResolvError - false - rescue Timeout::Error - retry unless (self.attempts -= 1).zero? - false - end - end - end -end diff --git a/lib/truemail/validate/smtp.rb b/lib/truemail/validate/smtp.rb index 4947845..5c3dd36 100644 --- a/lib/truemail/validate/smtp.rb +++ b/lib/truemail/validate/smtp.rb @@ -4,7 +4,7 @@ module Truemail module Validate class Smtp < Truemail::Validate::Base ERROR = 'smtp error' - ERROR_BODY = /(?=.*550)(?=.*(user|account)).*/i + ERROR_BODY = /(?=.*550)(?=.*(user|account|customer|mailbox)).*/i attr_reader :smtp_results diff --git a/lib/truemail/version.rb b/lib/truemail/version.rb index b1c74cd..be60ec9 100644 --- a/lib/truemail/version.rb +++ b/lib/truemail/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Truemail - VERSION = '0.1.6' + VERSION = '0.1.7' end diff --git a/lib/truemail/worker.rb b/lib/truemail/worker.rb new file mode 100644 index 0000000..9decd3a --- /dev/null +++ b/lib/truemail/worker.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Truemail + class Worker + attr_reader :result + + def self.check(result) + new(result).run + end + + def initialize(result) + @result = result + end + + private + + def success(condition) + result.success = condition || false + end + end +end diff --git a/lib/truemail/wrapper.rb b/lib/truemail/wrapper.rb new file mode 100644 index 0000000..350483f --- /dev/null +++ b/lib/truemail/wrapper.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Truemail + class Wrapper + attr_accessor :attempts + + def self.call(&block) + new.call(&block) + end + + def initialize + @attempts = Truemail.configuration.connection_attempts + end + + def call(&block) + Timeout.timeout(Truemail.configuration.connection_timeout, &block) + rescue Resolv::ResolvError + false + rescue Timeout::Error + retry unless (self.attempts -= 1).zero? + false + end + end +end diff --git a/spec/truemail/audit/ptr_spec.rb b/spec/truemail/audit/ptr_spec.rb new file mode 100644 index 0000000..17dc652 --- /dev/null +++ b/spec/truemail/audit/ptr_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +RSpec.describe Truemail::Audit::Ptr do + let(:email) { FFaker::Internet.email } + let(:result_instance) { Truemail::Auditor::Result.new } + + before { Truemail.configure { |config| config.verifier_email = email } } + + describe 'defined constants' do + specify { expect(described_class).to be_const_defined(:NOT_FOUND) } + specify { expect(described_class).to be_const_defined(:NOT_REFERENCES) } + end + + describe '.check' do + subject(:ptr_auditor) { described_class.check(result_instance) } + + let(:ptr_auditor_instance) { instance_double(described_class, run: true) } + + it 'receive #run' do + allow(described_class).to receive(:new).and_return(ptr_auditor_instance) + expect(ptr_auditor_instance).to receive(:run) + expect(ptr_auditor).to be(true) + end + end + + describe '#run' do + subject(:ptr_auditor) { ptr_auditor_instance.run } + + let(:ptr_auditor_instance) { described_class.new(result_instance) } + let(:host_name) { Truemail.configuration.verifier_domain } + let(:other_host_name) { 'localhost' } + + before do + allow(Socket).to receive(:gethostname).and_return(other_host_name) + allow(Resolv).to receive(:getaddress).and_return(FFaker::Internet.ip_v4_address) + end + + context 'when ptr record exists and refereces to verifier domain' do + it 'not changes warnings' do + expect(Resolv::DNS).to receive_message_chain(:new, :getresources, :map).and_return([host_name]) + expect { ptr_auditor }.to not_change(result_instance, :warnings) + end + end + + context 'when ptr record for current host address was not found' do + it 'addes not found warning' do + expect(Resolv::DNS).to receive_message_chain(:new, :getresources, :map).and_return([]) + expect { ptr_auditor }.to change(result_instance, :warnings).from({}).to({ ptr: Truemail::Audit::Ptr::NOT_FOUND }) + end + end + + context 'when ptr record checking crashes' do + it 'addes not found warning' do + expect(Resolv::DNS).to receive_message_chain(:new, :getresources, :map).and_raise(Resolv::ResolvError) + expect { ptr_auditor }.to change(result_instance, :warnings).from({}).to({ ptr: Truemail::Audit::Ptr::NOT_FOUND }) + end + end + + context 'when ptr record does not reference to current verifier domain' do + it 'addes not references warning' do + expect(Resolv::DNS).to receive_message_chain(:new, :getresources, :map).and_return([other_host_name]) + expect { ptr_auditor }.to change(result_instance, :warnings).from({}).to({ ptr: Truemail::Audit::Ptr::NOT_REFERENCES }) + end + end + end +end diff --git a/spec/truemail/auditor_spec.rb b/spec/truemail/auditor_spec.rb new file mode 100644 index 0000000..7703e04 --- /dev/null +++ b/spec/truemail/auditor_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Truemail + RSpec.describe Truemail::Auditor do + subject(:auditor_instance) { described_class.run } + + let(:email) { FFaker::Internet.email } + let(:auditor_instance_result) { auditor_instance.result } + + before { Truemail.configure { |config| config.verifier_email = email } } + + describe 'defined constants' do + specify { expect(described_class).to be_const_defined(:Result) } + end + + describe '.run' do + it 'creates and updates default auditor result' do + allow(Truemail::Audit::Ptr).to receive(:check) + expect(auditor_instance).to be_an_instance_of(Truemail::Auditor) + expect(auditor_instance_result).to be_an_instance_of(Truemail::Auditor::Result) + expect(auditor_instance_result.warnings).to eq({}) + end + end + end + + RSpec.describe Truemail::Auditor::Result do + subject(:result_instance) { described_class.new } + + let(:hash_object) { {} } + + specify do + expect(result_instance.members).to include(:warnings) + end + + it 'has default values for attributes' do + expect(result_instance.warnings).to eq(hash_object) + end + + it 'accepts parametrized arguments' do + expect(described_class.new(warnings: hash_object).warnings).to eq(hash_object) + end + end +end diff --git a/spec/truemail/validate/resolver_execution_wrapper_spec.rb b/spec/truemail/wrapper_spec.rb similarity index 96% rename from spec/truemail/validate/resolver_execution_wrapper_spec.rb rename to spec/truemail/wrapper_spec.rb index 3fac12d..da4035a 100644 --- a/spec/truemail/validate/resolver_execution_wrapper_spec.rb +++ b/spec/truemail/wrapper_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Truemail::Validate::ResolverExecutionWrapper do +RSpec.describe Truemail::Wrapper do let(:email) { FFaker::Internet.email } let(:method) { :hosts_from_mx_records? } let(:mx_instance) { instance_double(Truemail::Validate::Mx, method => true) } diff --git a/spec/truemail_spec.rb b/spec/truemail_spec.rb index 2d6e419..993fb56 100644 --- a/spec/truemail_spec.rb +++ b/spec/truemail_spec.rb @@ -4,14 +4,19 @@ let(:email) { FFaker::Internet.email } describe 'defined constants' do - specify { expect(described_class).to be_const_defined(:VERSION) } specify { expect(described_class).to be_const_defined(:INCOMPLETE_CONFIG) } specify { expect(described_class).to be_const_defined(:NOT_CONFIGURED) } + specify { expect(described_class).to be_const_defined(:VERSION) } + specify { expect(described_class).to be_const_defined(:Configuration) } + specify { expect(described_class).to be_const_defined(:Worker) } + specify { expect(described_class).to be_const_defined(:Wrapper) } + specify { expect(described_class).to be_const_defined(:Auditor) } + specify { expect(described_class).to be_const_defined(:Validator) } specify { expect(described_class).to be_const_defined(:ConfigurationError) } specify { expect(described_class).to be_const_defined(:ArgumentError) } specify { expect(described_class).to be_const_defined(:RegexConstant) } - specify { expect(described_class).to be_const_defined(:Configuration) } - specify { expect(described_class).to be_const_defined(:Validator) } + specify { expect(described_class).to be_const_defined(:Audit) } + specify { expect(described_class).to be_const_defined(:Validate) } end describe '.configure' do @@ -139,10 +144,28 @@ before { described_class.configure { |config| config.verifier_email = email } } - it 'returns boolean from result instance' do + it 'returns boolean from validator result instance' do allow(Truemail::Validate::Smtp).to receive(:check).and_return(true) allow_any_instance_of(Truemail::Validator::Result).to receive(:valid?).and_return(true) expect(valid_helper).to be(true) end end + + describe '.host_audit' do + subject(:host_audit) { described_class.host_audit } + + before do + described_class.configure { |config| config.verifier_email = email } + allow(Truemail::Auditor).to receive(:run).and_call_original + end + + it 'returns auditor instance' do + expect(host_audit).to be_an_instance_of(Truemail::Auditor) + end + + it 'runs checks for auditor result instance' do + expect(Truemail::Audit::Ptr).to receive(:check) + host_audit + end + end end