From 633c509a4509dcba9324e46a8a9cabe48784ec0a Mon Sep 17 00:00:00 2001 From: Adam Cooke Date: Thu, 29 Feb 2024 10:32:57 +0000 Subject: [PATCH] refactor: refactor the SMTP sender --- Gemfile.lock | 4 +- app/lib/dns_resolver.rb | 16 +- app/lib/smtp_client/endpoint.rb | 169 +++++++++ app/lib/smtp_client/server.rb | 35 ++ app/lib/smtp_client/ssl_modes.rb | 12 + app/senders/smtp_sender.rb | 452 +++++++++++----------- config/initializers/inflections.rb | 1 + config/initializers/smtp_extensions.rb | 2 + spec/lib/smtp_client/endpoint_spec.rb | 293 +++++++++++++++ spec/lib/smtp_client/server_spec.rb | 67 ++++ spec/senders/smtp_sender_spec.rb | 496 +++++++++++++++++++++++++ 11 files changed, 1291 insertions(+), 256 deletions(-) create mode 100644 app/lib/smtp_client/endpoint.rb create mode 100644 app/lib/smtp_client/server.rb create mode 100644 app/lib/smtp_client/ssl_modes.rb create mode 100644 spec/lib/smtp_client/endpoint_spec.rb create mode 100644 spec/lib/smtp_client/server_spec.rb create mode 100644 spec/senders/smtp_sender_spec.rb diff --git a/Gemfile.lock b/Gemfile.lock index 1555c31..4ef93f9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -132,7 +132,7 @@ GEM rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - json (2.6.3) + json (2.7.1) kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) @@ -145,7 +145,7 @@ GEM activerecord kaminari-core (= 1.2.2) kaminari-core (1.2.2) - klogger-logger (1.3.2) + klogger-logger (1.4.0) concurrent-ruby (>= 1.0, < 2.0) json rouge (>= 3.30, < 5.0) diff --git a/app/lib/dns_resolver.rb b/app/lib/dns_resolver.rb index f3b212e..3444120 100644 --- a/app/lib/dns_resolver.rb +++ b/app/lib/dns_resolver.rb @@ -75,17 +75,15 @@ class DNSResolver # @return [Array] def effective_ns(name) records = [] - dns do |dns| - parts = name.split(".") - (parts.size - 1).times do |n| - d = parts[n, parts.size - n + 1].join(".") + parts = name.split(".") + (parts.size - 1).times do |n| + d = parts[n, parts.size - n + 1].join(".") - records = get_resources(d, Resolv::DNS::Resource::IN::NS).map do |s| - s.name.to_s - end - - break if records.present? + records = get_resources(d, Resolv::DNS::Resource::IN::NS).map do |s| + s.name.to_s end + + break if records.present? end records diff --git a/app/lib/smtp_client/endpoint.rb b/app/lib/smtp_client/endpoint.rb new file mode 100644 index 0000000..5449e69 --- /dev/null +++ b/app/lib/smtp_client/endpoint.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +module SMTPClient + class Endpoint + + class SMTPSessionNotStartedError < StandardError + end + + attr_reader :server + attr_reader :ip_address + attr_accessor :smtp_client + + # @param server [Server] the server that this IP address is for + # @param ip_address [String] the IP address + def initialize(server, ip_address) + @server = server + @ip_address = ip_address + end + + # Return a description of this server with its IP address + # + # @return [String] + def description + "#{@ip_address}:#{@server.port} (#{@server.hostname})" + end + + # Return a string representation of this server + # + # @return [String] + def to_s + description + end + + # Return true if this is an IPv6 address + # + # @return [Boolean] + def ipv6? + @ip_address.include?(":") + end + + # Return true if this is an IPv4 address + # + # @return [Boolean] + def ipv4? + !ipv6? + end + + # Start a new SMTP session and store the client with this server for future use as needed + # + # @param source_ip_address [IPAddress] the IP address to use as the source address for the connection + # @param allow_ssl [Boolean] whether to allow SSL for this connection, if false SSL mode is ignored + # + # @return [Net::SMTP] + def start_smtp_session(source_ip_address: nil, allow_ssl: true) + @smtp_client = Net::SMTP.new(@ip_address, @server.port) + @smtp_client.open_timeout = Postal::Config.smtp_client.open_timeout + @smtp_client.read_timeout = Postal::Config.smtp_client.read_timeout + @smtp_client.tls_hostname = @server.hostname + + if source_ip_address + @source_ip_address = source_ip_address + end + + if @source_ip_address + @smtp_client.source_address = ipv6? ? @source_ip_address.ipv6 : @source_ip_address.ipv4 + end + + if allow_ssl + case @server.ssl_mode + when SSLModes::AUTO + @smtp_client.enable_starttls_auto(self.class.ssl_context_without_verify) + when SSLModes::STARTTLS + @smtp_client.enable_starttls(self.class.ssl_context_with_verify) + when SSLModes::TLS + @smtp_client.enable_tls(self.class.ssl_context_with_verify) + else + @smtp_client.disable_starttls + @smtp_client.disable_tls + end + else + @smtp_client.disable_starttls + @smtp_client.disable_tls + end + + @smtp_client.start(@source_ip_address ? @source_ip_address.hostname : self.class.default_helo_hostname) + + @smtp_client + end + + # Send a message to the current SMTP session (or create one if there isn't one for this endpoint). + # If sending messsage encouters some connection errors, retry again after re-establishing the SMTP + # session. + # + # @param raw_message [String] the raw message to send + # @param mail_from [String] the MAIL FROM address + # @param rcpt_to [String] the RCPT TO address + # @param retry_on_connection_error [Boolean] whether to retry the connection if there is a connection error + # + # @return [void] + def send_message(raw_message, mail_from, rcpt_to, retry_on_connection_error: true) + raise SMTPSessionNotStartedError if @smtp_client.nil? || (@smtp_client && !@smtp_client.started?) + + @smtp_client.rset_errors + @smtp_client.send_message(raw_message, mail_from, [rcpt_to]) + rescue Errno::ECONNRESET, Errno::EPIPE, OpenSSL::SSL::SSLError + if retry_on_connection_error + finish_smtp_session + start_smtp_session + return send_message(raw_message, mail_from, rcpt_to, retry_on_connection_error: false) + end + + raise + end + + # Reset the current SMTP session for this server if possible otherwise + # finish the session + # + # @return [void] + def reset_smtp_session + @smtp_client&.rset + rescue StandardError + finish_smtp_session + end + + # Finish the current SMTP session for this server if possible. + # + # @return [void] + def finish_smtp_session + @smtp_client&.finish + rescue StandardError + nil + ensure + @smtp_client = nil + end + + class << self + + # Return the default HELO hostname to present to SMTP servers that + # we connect to + # + # @return [String] + def default_helo_hostname + Postal::Config.dns.helo_hostname || + Postal::Config.postal.smtp_hostname || + "localhost" + end + + def ssl_context_with_verify + @ssl_context_with_verify ||= begin + c = OpenSSL::SSL::SSLContext.new + c.verify_mode = OpenSSL::SSL::VERIFY_PEER + c.cert_store = OpenSSL::X509::Store.new + c.cert_store.set_default_paths + c + end + end + + def ssl_context_without_verify + @ssl_context_without_verify ||= begin + c = OpenSSL::SSL::SSLContext.new + c.verify_mode = OpenSSL::SSL::VERIFY_NONE + c + end + end + + end + + end +end diff --git a/app/lib/smtp_client/server.rb b/app/lib/smtp_client/server.rb new file mode 100644 index 0000000..8630fff --- /dev/null +++ b/app/lib/smtp_client/server.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module SMTPClient + class Server + + attr_reader :hostname + attr_reader :port + attr_accessor :ssl_mode + + def initialize(hostname, port: 25, ssl_mode: SSLModes::AUTO) + @hostname = hostname + @port = port + @ssl_mode = ssl_mode + end + + # Return all IP addresses for this server by resolving its hostname. + # IPv6 addresses will be returned first. + # + # @return [Array] + def endpoints + ips = [] + + DNSResolver.local.aaaa(@hostname).each do |ip| + ips << Endpoint.new(self, ip) + end + + DNSResolver.local.a(@hostname).each do |ip| + ips << Endpoint.new(self, ip) + end + + ips + end + + end +end diff --git a/app/lib/smtp_client/ssl_modes.rb b/app/lib/smtp_client/ssl_modes.rb new file mode 100644 index 0000000..0d58763 --- /dev/null +++ b/app/lib/smtp_client/ssl_modes.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module SMTPClient + module SSLModes + + AUTO = "Auto" + STARTTLS = "STARTLS" + TLS = "TLS" + NONE = "None" + + end +end diff --git a/app/senders/smtp_sender.rb b/app/senders/smtp_sender.rb index f185cd8..ddf01e0 100644 --- a/app/senders/smtp_sender.rb +++ b/app/senders/smtp_sender.rb @@ -2,293 +2,255 @@ class SMTPSender < BaseSender - def initialize(domain, source_ip_address, options = {}) + attr_reader :endpoints + + # @param domain [String] the domain to send mesages to + # @param source_ip_address [IPAddress] the IP address to send messages from + # @param log_id [String] an ID to use when logging requests + def initialize(domain, source_ip_address = nil, servers: nil, log_id: nil, rcpt_to: nil) super() @domain = domain @source_ip_address = source_ip_address - @options = options - @smtp_client = nil + @rcpt_to = rcpt_to + + # An array of servers to forcefully send the message to + @servers = servers + # Stores all connection errors which we have seen during this send sesssion. @connection_errors = [] - @hostnames = [] - @log_id = Nifty::Utils::RandomString.generate(length: 8).upcase + # Stores all endpoints that we have attempted to deliver mail to + @endpoints = [] + # Generate a log ID which can be used if none has been provided to trace + # this SMTP session. + @log_id = log_id || SecureRandom.alphanumeric(8).upcase end def start + servers = @servers || self.class.smtp_relays || resolve_mx_records_for_domain || [] + servers.each do |server| - if server.is_a?(SMTPEndpoint) - hostname = server.hostname - port = server.port || 25 - ssl_mode = server.ssl_mode - elsif server.is_a?(Hash) - hostname = server[:hostname] - port = server[:port] || 25 - ssl_mode = server[:ssl_mode] || "Auto" - else - hostname = server - port = 25 - ssl_mode = "Auto" - end - - @hostnames << hostname - [:aaaa, :a].each do |ip_type| - if @source_ip_address && @source_ip_address.ipv6.blank? && ip_type == :aaaa - # Don't try to use IPv6 if the IP address we're sending from doesn't support it. - next - end - - begin - @remote_ip = lookup_ip_address(ip_type, hostname) - if @remote_ip.nil? - if ip_type == :a - # As we can't resolve the last IP, we'll put this - @connection_errors << "Could not resolve #{hostname}" - end - next - end - - smtp_client = Net::SMTP.new(@remote_ip, port) - smtp_client.open_timeout = Postal::Config.smtp_client.open_timeout - smtp_client.read_timeout = Postal::Config.smtp_client.read_timeout - smtp_client.tls_hostname = hostname - - if @source_ip_address - # Set the source IP as appropriate - smtp_client.source_address = ip_type == :aaaa ? @source_ip_address.ipv6 : @source_ip_address.ipv4 - end - - case ssl_mode - when "Auto" - smtp_client.enable_starttls_auto(self.class.ssl_context_without_verify) - when "STARTTLS" - smtp_client.enable_starttls(self.class.ssl_context_with_verify) - when "TLS" - smtp_client.enable_tls(self.class.ssl_context_with_verify) - else - smtp_client.disable_starttls - smtp_client.disable_tls - end - - smtp_client.start(@source_ip_address ? @source_ip_address.hostname : self.class.default_helo_hostname) - log "Connected to #{@remote_ip}:#{port} (#{hostname})" - rescue StandardError => e - if e.is_a?(OpenSSL::SSL::SSLError) && ssl_mode == "Auto" - log "SSL error (#{e.message}), retrying without SSL" - ssl_mode = nil - retry - end - - log "Cannot connect to #{@remote_ip}:#{port} (#{hostname}) (#{e.class}: #{e.message})" - @connection_errors << e.message unless @connection_errors.include?(e.message) - begin - smtp_client.finish - rescue StandardError - nil - end - smtp_client = nil - end - - if smtp_client - @smtp_client = smtp_client - return true - end + server.endpoints.each do |endpoint| + result = connect_to_endpoint(endpoint) + return endpoint if result end end - @connection_errors + false end - def reconnect - log "Reconnecting" - begin - @smtp_client&.finish - rescue StandardError - nil - end - start - end - - def safe_rset - # Something went wrong sending the last email. Reset the connection if possible, else disconnect. - - @smtp_client.rset - rescue StandardError - # Don't reconnect, this would be rather rude if we don't have any more emails to send. - begin - @smtp_client.finish - rescue StandardError - nil - end - end - - def send_message(message, force_rcpt_to = nil) - start_time = Time.now - result = SendResult.new - result.log_id = @log_id - if @smtp_client && !@smtp_client.started? - # For some reason we had an SMTP connection but it's no longer connected. - # Make a new one. - start - end - - if @smtp_client - result.secure = @smtp_client.secure_socket? - end - - begin - if message.bounce - mail_from = "" - elsif message.domain.return_path_status == "OK" - mail_from = "#{message.server.token}@#{message.domain.return_path_domain}" - else - mail_from = "#{message.server.token}@#{Postal::Config.dns.return_path_domain}" - end - if Postal::Config.postal.use_resent_sender_header - raw_message = "Resent-Sender: #{mail_from}\r\n" + message.raw_message - else - raw_message = message.raw_message - end - tries = 0 - begin - if @smtp_client.nil? - log "-> No SMTP server available for #{@domain}" - log "-> Hostnames: #{@hostnames.inspect}" - log "-> Errors: #{@connection_errors.inspect}" - result.type = "SoftFail" - result.retry = true - result.details = "No SMTP servers were available for #{@domain}. Tried #{@hostnames.to_sentence}" - result.output = @connection_errors.join(", ") - result.connect_error = true - return result + def send_message(message) + # If we don't have a current endpoint than we should raise an error. + if @current_endpoint.nil? + return create_result("SoftFail") do |r| + r.retry = true + r.details = "No SMTP servers were available for #{@domain}." + if @endpoints.empty? + r.details += " No hosts to try." else - @smtp_client.rset_errors - rcpt_to = force_rcpt_to || @options[:force_rcpt_to] || message.rcpt_to - log "Sending message #{message.server.id}::#{message.id} to #{rcpt_to}" - smtp_result = @smtp_client.send_message(raw_message, mail_from, [rcpt_to]) + hostnames = @endpoints.map { |e| e.server.hostname }.uniq + r.details += " Tried #{hostnames.to_sentence}." end - rescue Errno::ECONNRESET, Errno::EPIPE, OpenSSL::SSL::SSLError - raise unless (tries += 1) < 2 - - reconnect - retry + r.output = @connection_errors.join(", ") + r.connect_error = true end - result.type = "Sent" - result.details = "Message for #{rcpt_to} accepted by #{destination_host_description}" - if @smtp_client.source_address - result.details += " (from #{@smtp_client.source_address})" - end - result.output = smtp_result.string - log "Message sent ##{message.id} to #{destination_host_description} for #{rcpt_to}" - rescue Net::SMTPServerBusy, Net::SMTPAuthenticationError, Net::SMTPSyntaxError, Net::SMTPUnknownError, Net::ReadTimeout => e - log "#{e.class}: #{e.message}" - result.type = "SoftFail" - result.retry = true - result.details = "Temporary SMTP delivery error when sending to #{destination_host_description}" - result.output = e.message - if e.to_s =~ /(\d+) seconds/ - result.retry = ::Regexp.last_match(1).to_i + 10 - elsif e.to_s =~ /(\d+) minutes/ - result.retry = (::Regexp.last_match(1).to_i * 60) + 10 - end - - safe_rset - rescue Net::SMTPFatalError => e - log "#{e.class}: #{e.message}" - result.type = "HardFail" - result.details = "Permanent SMTP delivery error when sending to #{destination_host_description}" - result.output = e.message - safe_rset - rescue StandardError => e - log "#{e.class}: #{e.message}" - if defined?(Sentry) - Sentry.capture_exception(e, extra: { log_id: @log_id, server_id: message.server.id, message_id: message.id }) - end - result.type = "SoftFail" - result.retry = true - result.details = "An error occurred while sending the message to #{destination_host_description}" - result.output = e.message - safe_rset end - result.time = (Time.now - start_time).to_f.round(2) - result + mail_from = determine_mail_from_for_message(message) + raw_message = message.raw_message + + # Append the Resent-Sender header to the mesage to include the + # MAIL FROM if the installation is configured to use that? + if Postal::Config.postal.use_resent_sender_header? + raw_message = "Resent-Sender: #{mail_from}\r\n" + raw_message + end + + rcpt_to = determine_rcpt_to_for_message(message) + logger.info "Sending message #{message.server.id}::#{message.id} to #{rcpt_to}" + send_message_to_smtp_client(raw_message, mail_from, rcpt_to) end def finish - log "Finishing up" - @smtp_client&.finish + @endpoints.each(&:finish_smtp_session) end private - def servers - @options[:servers] || self.class.relay_hosts || @servers ||= begin - mx_servers = DNSResolver.local.mx(@domain).map(&:last) - if mx_servers.empty? - mx_servers = [@domain] # This will be resolved to an A or AAAA record later + # Take a message and attempt to send it to the SMTP server that we are + # currently connected to. If there is a connection error, we will just + # reset the client and retry again once. + # + # @param raw_message [String] the raw message to send + # @param mail_from [String] the MAIL FROM address to use + # @param rcpt_to [String] the RCPT TO address to use + # @param retry_on_connection_error [Boolean] if true, we will retry the connection if there is an error + # + # @return [SendResult] + def send_message_to_smtp_client(raw_message, mail_from, rcpt_to, retry_on_connection_error: true) + start_time = Time.now + smtp_result = @current_endpoint.send_message(raw_message, mail_from, [rcpt_to]) + logger.info "Accepted by #{@current_endpoint} for #{rcpt_to}" + create_result("Sent", start_time) do |r| + r.details = "Message for #{rcpt_to} accepted by #{@current_endpoint}" + r.details += " (from #{@current_endpoint.smtp_client.source_address})" if @current_endpoint.smtp_client.source_address + r.output = smtp_result.string + end + rescue Net::SMTPServerBusy, Net::SMTPAuthenticationError, Net::SMTPSyntaxError, Net::SMTPUnknownError, Net::ReadTimeout => e + logger.error "#{e.class}: #{e.message}" + @current_endpoint.reset_smtp_session + + create_result("SoftFail", start_time) do |r| + r.details = "Temporary SMTP delivery error when sending to #{@current_endpoint}" + r.output = e.message + if e.message =~ /(\d+) seconds/ + r.retry = ::Regexp.last_match(1).to_i + 10 + elsif e.message =~ /(\d+) minutes/ + r.retry = (::Regexp.last_match(1).to_i * 60) + 10 + else + r.retry = true end - mx_servers + end + rescue Net::SMTPFatalError => e + logger.error "#{e.class}: #{e.message}" + @current_endpoint.reset_smtp_session + + create_result("HardFail", start_time) do |r| + r.details = "Permanent SMTP delivery error when sending to #{@current_endpoint}" + r.output = e.message + end + rescue StandardError => e + logger.error "#{e.class}: #{e.message}" + @current_endpoint.reset_smtp_session + + if defined?(Sentry) + # Sentry.capture_exception(e, extra: { log_id: @log_id, server_id: message.server.id, message_id: message.id }) + end + + create_result("SoftFail", start_time) do |r| + r.type = "SoftFail" + r.retry = true + r.details = "An error occurred while sending the message to #{@current_endpoint}" + r.output = e.message end end - def log(text) - Postal.logger.info text, id: @log_id, component: "smtp-sender" - end + # Return the MAIL FROM which should be used for the given message + # + # @param message [MessageDB::Message] + # @return [String] + def determine_mail_from_for_message(message) + return "" if message.bounce - def destination_host_description - "#{@hostnames.last} (#{@remote_ip})" - end - - def lookup_ip_address(type, hostname) - records = [] - case type - when :a - records = DNSResolver.local.a(hostname) - when :aaaa - records = DNSResolver.local.aaaa(hostname) + # If the domain has a valid custom return path configured, return + # that. + if message.domain.return_path_status == "OK" + return "#{message.server.token}@#{message.domain.return_path_domain}" end - records.first&.to_s&.downcase + + "#{message.server.token}@#{Postal::Config.dns.return_path_domain}" + end + + # Return the RCPT TO to use for the given message in this sending session + # + # @param message [MessageDB::Message] + # @return [String] + def determine_rcpt_to_for_message(message) + return @rcpt_to if @rcpt_to + + message.rcpt_to + end + + # Return an array of server hostnames which should receive this message + # + # @return [Array] + def resolve_mx_records_for_domain + hostnames = DNSResolver.local.mx(@domain).map(&:last) + return [SMTPClient::Server.new(@domain)] if hostnames.empty? + + hostnames.map { |hostname| SMTPClient::Server.new(hostname) } + end + + # Attempt to begin an SMTP sesssion for the given endpoint. If successful, this endpoint + # becomes the current endpoints for the SMTP sender. + # + # Returns true if the session was established. + # Returns false if the session could not be established. + # + # @param endpoint [SMTPClient::Endpoint] + # @return [Boolean] + def connect_to_endpoint(endpoint, allow_ssl: true) + if @source_ip_address && @source_ip_address.ipv6.blank? && endpoint.ipv6? + # Don't try to use IPv6 if the IP address we're sending from doesn't support it. + return false + end + + # Add this endpoint to the list of endpoints that we have attempted to connect to + @endpoints << endpoint unless @endpoints.include?(endpoint) + + endpoint.start_smtp_session(allow_ssl: allow_ssl, source_ip_address: @source_ip_address) + logger.info "Connected to #{endpoint}" + @current_endpoint = endpoint + + true + rescue StandardError => e + # Disconnect the SMTP client if we get any errors to avoid leaving + # a connection around. + endpoint.finish_smtp_session + + # If we get an SSL error, we can retry a connection without + # ssl. + if e.is_a?(OpenSSL::SSL::SSLError) && endpoint.server.ssl_mode == "Auto" + logger.error "SSL error (#{e.message}), retrying without SSL" + return connect_to_endpoint(endpoint, allow_ssl: false) + end + + # Otherwise, just log the connection error and return false + logger.error "Cannot connect to #{endpoint} (#{e.class}: #{e.message})" + @connection_errors << e.message unless @connection_errors.include?(e.message) + + false + end + + # Create a new result object + # + # @param type [String] the type of result + # @param start_time [Time] the time the operation started + # @yieldparam [SendResult] the result object + # @yieldreturn [void] + # + # @return [SendResult] + def create_result(type, start_time = nil) + result = SendResult.new + result.type = type + result.log_id = @log_id + result.secure = @current_endpoint&.smtp_client&.secure_socket? ? true : false + yield result if block_given? + if start_time + result.time = (Time.now - start_time).to_f.round(2) + end + result + end + + def logger + @logger ||= Postal.logger.create_tagged_logger(log_id: @log_id) end class << self - def ssl_context_with_verify - @ssl_context_with_verify ||= begin - c = OpenSSL::SSL::SSLContext.new - c.verify_mode = OpenSSL::SSL::VERIFY_PEER - c.cert_store = OpenSSL::X509::Store.new - c.cert_store.set_default_paths - c - end - end + # Return an array of SMTP relays as configured. Returns nil + # if no SMTP relays are configured. + # + def smtp_relays + return @smtp_relays if instance_variable_defined?("@smtp_relays") - def ssl_context_without_verify - @ssl_context_without_verify ||= begin - c = OpenSSL::SSL::SSLContext.new - c.verify_mode = OpenSSL::SSL::VERIFY_NONE - c - end - end - - def default_helo_hostname - Postal::Config.dns.helo_hostname || - Postal::Config.postal.smtp_hostname || - "localhost" - end - - def relay_hosts relays = Postal::Config.postal.smtp_relays return nil if relays.nil? - hosts = relays.map do |relay| + relays.map do |relay| next unless relay.host.present? - { - hostname: relay.host, - port: relay.port, - ssl_mode: relay.ssl_mode - } + SMTPClient::Server.new(relay.host, relay.port, ssl_mode: relay.ssl_mode) end.compact - hosts.empty? ? nil : hosts + + @smtp_relays = hosts.empty? ? nil : hosts end end diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index da17573..3a68b27 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -21,6 +21,7 @@ ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.acronym "API" inflect.acronym "DNS" + inflect.acronym "SSL" inflect.acronym "MySQL" inflect.acronym "DB" diff --git a/config/initializers/smtp_extensions.rb b/config/initializers/smtp_extensions.rb index f6db89c..e097bee 100644 --- a/config/initializers/smtp_extensions.rb +++ b/config/initializers/smtp_extensions.rb @@ -6,6 +6,8 @@ module Net attr_accessor :source_address def secure_socket? + return false unless @socket + @socket.io.is_a?(OpenSSL::SSL::SSLSocket) end diff --git a/spec/lib/smtp_client/endpoint_spec.rb b/spec/lib/smtp_client/endpoint_spec.rb new file mode 100644 index 0000000..5af3267 --- /dev/null +++ b/spec/lib/smtp_client/endpoint_spec.rb @@ -0,0 +1,293 @@ +# frozen_string_literal: true + +require "rails_helper" + +module SMTPClient + + RSpec.describe Endpoint do + let(:ssl_mode) { SSLModes::AUTO } + let(:server) { Server.new("mx1.example.com", port: 25, ssl_mode: ssl_mode) } + let(:ip) { "1.2.3.4" } + + before do + allow(Net::SMTP).to receive(:new).and_wrap_original do |original_method, *args| + smtp = original_method.call(*args) + allow(smtp).to receive(:start) + allow(smtp).to receive(:started?).and_return(true) + allow(smtp).to receive(:send_message) + allow(smtp).to receive(:finish) + smtp + end + end + + subject(:endpoint) { described_class.new(server, ip) } + + describe "#description" do + it "returns a description for the endpoint" do + expect(endpoint.description).to eq "1.2.3.4:25 (mx1.example.com)" + end + end + + describe "#ipv6?" do + context "when the IP address is an IPv6 address" do + let(:ip) { "2a00:67a0:a::1" } + + it "returns true" do + expect(endpoint.ipv6?).to be true + end + end + + context "when the IP address is an IPv4 address" do + it "returns false" do + expect(endpoint.ipv6?).to be false + end + end + end + + describe "#ipv4?" do + context "when the IP address is an IPv4 address" do + it "returns true" do + expect(endpoint.ipv4?).to be true + end + end + + context "when the IP address is an IPv6 address" do + let(:ip) { "2a00:67a0:a::1" } + + it "returns false" do + expect(endpoint.ipv4?).to be false + end + end + end + + describe "#start_smtp_session" do + context "when given no source IP address" do + it "creates a new Net::SMTP client with appropriate details" do + client = endpoint.start_smtp_session + expect(client.address).to eq "1.2.3.4" + end + + it "sets the appropriate timeouts from the config" do + client = endpoint.start_smtp_session + expect(client.open_timeout).to eq Postal::Config.smtp_client.open_timeout + expect(client.read_timeout).to eq Postal::Config.smtp_client.read_timeout + end + + it "does not set a source address" do + client = endpoint.start_smtp_session + expect(client.source_address).to be_nil + end + + it "sets the TLS hostname" do + client = endpoint.start_smtp_session + expect(client.tls_hostname).to eq "mx1.example.com" + end + + it "starts the SMTP client the default HELO" do + endpoint.start_smtp_session + expect(endpoint.smtp_client).to have_received(:start).with(Postal::Config.postal.smtp_hostname) + end + + context "when the SSL mode is Auto" do + it "enables STARTTLS auto " do + client = endpoint.start_smtp_session + expect(client.starttls?).to eq :auto + end + end + + context "when the SSL mode is STARTLS" do + let(:ssl_mode) { SSLModes::STARTTLS } + + it "as starttls as always" do + client = endpoint.start_smtp_session + expect(client.starttls?).to eq :always + end + end + + context "when the SSL mode is TLS" do + let(:ssl_mode) { SSLModes::TLS } + + it "as starttls as always" do + client = endpoint.start_smtp_session + expect(client.tls?).to be true + end + end + + context "when the SSL mode is None" do + let(:ssl_mode) { SSLModes::NONE } + + it "disables STARTTLS and TLS" do + client = endpoint.start_smtp_session + expect(client.starttls?).to be false + expect(client.tls?).to be false + end + end + + context "when the SSL mode is Auto but ssl_allow is false" do + it "disables STARTTLS and TLS" do + client = endpoint.start_smtp_session(allow_ssl: false) + expect(client.starttls?).to be false + expect(client.tls?).to be false + end + end + end + + context "when given a source IP address" do + let(:ip_address) { create(:ip_address) } + + context "when the endpoint IP is ipv4" do + it "sets the source address to the IPv4 address" do + client = endpoint.start_smtp_session(source_ip_address: ip_address) + expect(client.source_address).to eq ip_address.ipv4 + end + end + + context "when the endpoint IP is ipv6" do + let(:ip) { "2a00:67a0:a::1" } + + it "sets the source address to the IPv6 address" do + client = endpoint.start_smtp_session(source_ip_address: ip_address) + expect(client.source_address).to eq ip_address.ipv6 + end + end + + it "starts the SMTP client with the IP addresses hostname" do + endpoint.start_smtp_session(source_ip_address: ip_address) + expect(endpoint.smtp_client).to have_received(:start).with(ip_address.hostname) + end + end + end + + describe "#send_message" do + context "when the smtp client has not been created" do + it "raises an error" do + expect { endpoint.send_message("", "", "") }.to raise_error Endpoint::SMTPSessionNotStartedError + end + end + + context "when the smtp client exists but is not started" do + it "raises an error" do + endpoint.start_smtp_session + expect(endpoint.smtp_client).to receive(:started?).and_return(false) + expect { endpoint.send_message("", "", "") }.to raise_error Endpoint::SMTPSessionNotStartedError + end + end + + context "when the smtp client is started" do + before do + endpoint.start_smtp_session + end + + it "resets any previous errors" do + expect(endpoint.smtp_client).to receive(:rset_errors) + endpoint.send_message("test message", "from@example.com", "to@example.com") + end + + it "sends the message to the SMTP client" do + endpoint.send_message("test message", "from@example.com", "to@example.com") + expect(endpoint.smtp_client).to have_received(:send_message).with("test message", "from@example.com", ["to@example.com"]) + end + + context "when the connection is reset during sending" do + before do + endpoint.start_smtp_session + allow(endpoint.smtp_client).to receive(:send_message) do + raise Errno::ECONNRESET + end + end + + it "closes the SMTP client" do + expect(endpoint).to receive(:finish_smtp_session).and_call_original + endpoint.send_message("test message", "", "") + end + + it "retries sending the message once" do + expect(endpoint).to receive(:send_message).twice.and_call_original + endpoint.send_message("test message", "", "") + end + + context "if the retry also fails" do + it "raises the error" do + allow(endpoint).to receive(:send_message).and_raise(Errno::ECONNRESET) + expect { endpoint.send_message("test message", "", "") }.to raise_error(Errno::ECONNRESET) + end + end + end + end + end + + describe "#reset_smtp_session" do + it "calls rset on the client" do + endpoint.start_smtp_session + expect(endpoint.smtp_client).to receive(:rset) + endpoint.reset_smtp_session + end + + context "if there is an error" do + it "finishes the smtp client" do + endpoint.start_smtp_session + allow(endpoint.smtp_client).to receive(:rset).and_raise(StandardError) + expect(endpoint).to receive(:finish_smtp_session) + endpoint.reset_smtp_session + end + end + end + + describe "#finish_smtp_session" do + it "calls finish on the client" do + endpoint.start_smtp_session + expect(endpoint.smtp_client).to receive(:finish) + endpoint.finish_smtp_session + end + + it "sets the smtp client to nil" do + endpoint.start_smtp_session + endpoint.finish_smtp_session + expect(endpoint.smtp_client).to be_nil + end + + context "if the client finish raises an error" do + it "does not raise it" do + endpoint.start_smtp_session + allow(endpoint.smtp_client).to receive(:finish).and_raise(StandardError) + expect { endpoint.finish_smtp_session }.not_to raise_error + end + end + end + + describe ".default_helo_hostname" do + context "when the configuration specifies a helo hostname" do + before do + allow(Postal::Config.dns).to receive(:helo_hostname).and_return("helo.example.com") + end + + it "returns that" do + expect(described_class.default_helo_hostname).to eq "helo.example.com" + end + end + + context "when the configuration does not specify a helo hostname but has an smtp hostname" do + before do + allow(Postal::Config.dns).to receive(:helo_hostname).and_return(nil) + allow(Postal::Config.postal).to receive(:smtp_hostname).and_return("smtp.example.com") + end + + it "returns the smtp hostname" do + expect(described_class.default_helo_hostname).to eq "smtp.example.com" + end + end + + context "when the configuration has neither a helo hostname or an smtp hostname" do + before do + allow(Postal::Config.dns).to receive(:helo_hostname).and_return(nil) + allow(Postal::Config.postal).to receive(:smtp_hostname).and_return(nil) + end + + it "returns localhost" do + expect(described_class.default_helo_hostname).to eq "localhost" + end + end + end + end + +end diff --git a/spec/lib/smtp_client/server_spec.rb b/spec/lib/smtp_client/server_spec.rb new file mode 100644 index 0000000..20e4ff1 --- /dev/null +++ b/spec/lib/smtp_client/server_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "rails_helper" + +module SMTPClient + + RSpec.describe Server do + let(:hostname) { "example.com" } + let(:port) { 25 } + let(:ssl_mode) { SSLModes::AUTO } + + subject(:server) { described_class.new(hostname, port: port, ssl_mode: ssl_mode) } + + describe "#endpoints" do + context "when there are A and AAAA records" do + before do + allow(DNSResolver.local).to receive(:a).and_return(["1.2.3.4", "2.3.4.5"]) + allow(DNSResolver.local).to receive(:aaaa).and_return(["2a00::67a0:a::1234", "2a00::67a0:a::2345"]) + end + + it "asks the resolver for the A and AAAA records for the hostname" do + server.endpoints + expect(DNSResolver.local).to have_received(:a).with(hostname).once + expect(DNSResolver.local).to have_received(:aaaa).with(hostname).once + end + + it "returns endpoints for ipv6 addresses followed by ipv4" do + expect(server.endpoints).to match [ + have_attributes(ip_address: "2a00::67a0:a::1234"), + have_attributes(ip_address: "2a00::67a0:a::2345"), + have_attributes(ip_address: "1.2.3.4"), + have_attributes(ip_address: "2.3.4.5") + ] + end + end + + context "when there are just A records" do + before do + allow(DNSResolver.local).to receive(:a).and_return(["1.2.3.4", "2.3.4.5"]) + allow(DNSResolver.local).to receive(:aaaa).and_return([]) + end + + it "returns ipv4 endpoints" do + expect(server.endpoints).to match [ + have_attributes(ip_address: "1.2.3.4"), + have_attributes(ip_address: "2.3.4.5") + ] + end + end + + context "when there are just AAAA records" do + before do + allow(DNSResolver.local).to receive(:a).and_return([]) + allow(DNSResolver.local).to receive(:aaaa).and_return(["2a00::67a0:a::1234", "2a00::67a0:a::2345"]) + end + + it "returns ipv6 endpoints" do + expect(server.endpoints).to match [ + have_attributes(ip_address: "2a00::67a0:a::1234"), + have_attributes(ip_address: "2a00::67a0:a::2345") + ] + end + end + end + end + +end diff --git a/spec/senders/smtp_sender_spec.rb b/spec/senders/smtp_sender_spec.rb new file mode 100644 index 0000000..5c7816b --- /dev/null +++ b/spec/senders/smtp_sender_spec.rb @@ -0,0 +1,496 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SMTPSender do + subject(:sender) { described_class.new("example.com") } + + let(:smtp_start_error) { nil } + let(:smtp_send_message_error) { nil } + let(:smtp_send_message_result) { double("Result", string: "accepted") } + + before do + # Mock the SMTP client endpoint so that we can avoid making any actual + # SMTP connections but still mock things as appropriate. + allow(SMTPClient::Endpoint).to receive(:new).and_wrap_original do |original, *args, **kwargs| + endpoint = original.call(*args, **kwargs) + + allow(endpoint).to receive(:start_smtp_session) do |**ikwargs| + if error = smtp_start_error&.call(endpoint, ikwargs[:allow_ssl]) + raise error + end + end + + allow(endpoint).to receive(:send_message) do |message| + if error = smtp_send_message_error&.call(endpoint, message) + raise error + end + + smtp_send_message_result + end + allow(endpoint).to receive(:finish_smtp_session) + allow(endpoint).to receive(:reset_smtp_session) + allow(endpoint).to receive(:smtp_client) do + Net::SMTP.new(endpoint.ip_address, endpoint.server.port) + end + endpoint + end + end + + before do + # Override the DNS resolver to return empty arrays by default for A and AAAA + # DNS lookups to avoid making requests to public servers. + allow(DNSResolver.local).to receive(:aaaa).and_return([]) + allow(DNSResolver.local).to receive(:a).and_return([]) + end + + describe "#start" do + context "when no servers are provided to the class and there are no SMTP relays" do + before do + allow(DNSResolver.local).to receive(:mx).and_return([[5, "mx1.example.com"], [10, "mx2.example.com"]]) + allow(DNSResolver.local).to receive(:a).with("mx1.example.com").and_return(["1.2.3.4"]) + allow(DNSResolver.local).to receive(:a).with("mx2.example.com").and_return(["6.7.8.9"]) + end + + it "attempts to create an SMTP connection for each endpoint for each MX server for them" do + endpoint = sender.start + expect(endpoint).to be_a SMTPClient::Endpoint + expect(endpoint).to have_attributes( + ip_address: "1.2.3.4", + server: have_attributes(hostname: "mx1.example.com", port: 25, ssl_mode: SMTPClient::SSLModes::AUTO) + ) + end + end + + context "when there are no servers provided to the class but there are SMTP relays" do + before do + allow(SMTPSender).to receive(:smtp_relays).and_return([SMTPClient::Server.new("relay.example.com", port: 2525, ssl_mode: SMTPClient::SSLModes::TLS)]) + allow(DNSResolver.local).to receive(:a).with("relay.example.com").and_return(["1.2.3.4"]) + end + + it "attempts to use the relays" do + endpoint = sender.start + expect(endpoint).to be_a SMTPClient::Endpoint + expect(endpoint).to have_attributes( + ip_address: "1.2.3.4", + server: have_attributes(hostname: "relay.example.com", port: 2525, ssl_mode: SMTPClient::SSLModes::TLS) + ) + end + end + + context "when there are servers provided to the class" do + let(:server) { SMTPClient::Server.new("custom.example.com") } + + subject(:sender) { described_class.new("example.com", servers: [server]) } + + before do + allow(DNSResolver.local).to receive(:a).with("custom.example.com").and_return(["1.2.3.4"]) + end + + it "uses the provided servers" do + endpoint = sender.start + expect(endpoint).to be_a SMTPClient::Endpoint + expect(endpoint).to have_attributes( + ip_address: "1.2.3.4", + server: server + ) + end + end + + context "when a source IP is given without IPv6 and an endpoint is IPv6 enabled" do + let(:source_ip_address) { create(:ip_address, ipv6: nil) } + let(:server) { SMTPClient::Server.new("custom.example.com") } + subject(:sender) { described_class.new("example.com", source_ip_address, servers: [server]) } + + before do + allow(DNSResolver.local).to receive(:aaaa).with("custom.example.com").and_return(["2a00:67a0:a::1"]) + allow(DNSResolver.local).to receive(:a).with("custom.example.com").and_return(["1.2.3.4"]) + end + + it "returns the IPv4 version" do + endpoint = sender.start + expect(endpoint).to be_a SMTPClient::Endpoint + expect(endpoint).to have_attributes( + ip_address: "1.2.3.4", + server: server + ) + end + end + + context "when there are no servers to connect to" do + it "returns false" do + expect(sender.start).to be false + end + end + + context "when the first server tried cannot be connected to" do + let(:server1) { SMTPClient::Server.new("custom1.example.com") } + let(:server2) { SMTPClient::Server.new("custom2.example.com") } + + let(:smtp_start_error) do + proc do |endpoint| + Errno::ECONNREFUSED if endpoint.ip_address == "1.2.3.4" + end + end + + before do + allow(DNSResolver.local).to receive(:a).with("custom1.example.com").and_return(["1.2.3.4"]) + allow(DNSResolver.local).to receive(:a).with("custom2.example.com").and_return(["2.3.4.5"]) + end + + subject(:sender) { described_class.new("example.com", servers: [server1, server2]) } + + it "tries the second" do + endpoint = sender.start + expect(endpoint).to be_a SMTPClient::Endpoint + expect(endpoint).to have_attributes( + ip_address: "2.3.4.5", + server: have_attributes(hostname: "custom2.example.com") + ) + end + + it "includes both endpoints in the array of endpoints tried" do + sender.start + expect(sender.endpoints).to match([have_attributes(ip_address: "1.2.3.4"), + have_attributes(ip_address: "2.3.4.5")]) + end + end + + context "when the server returns an SSL error and SSL mode is Auto" do + let(:server) { SMTPClient::Server.new("custom.example.com") } + + let(:smtp_start_error) do + proc do |endpoint, allow_ssl| + OpenSSL::SSL::SSLError if allow_ssl && endpoint.server.ssl_mode == "Auto" + end + end + + before do + allow(DNSResolver.local).to receive(:aaaa).with("custom.example.com").and_return([]) + allow(DNSResolver.local).to receive(:a).with("custom.example.com").and_return(["1.2.3.4"]) + end + + subject(:sender) { described_class.new("example.com", servers: [server]) } + + it "attempts to reconnect without SSL" do + endpoint = sender.start + expect(endpoint).to be_a SMTPClient::Endpoint + expect(endpoint).to have_attributes(ip_address: "1.2.3.4") + end + end + end + + describe "#send_message" do + let(:server) { create(:server) } + let(:domain) { create(:domain, server: server) } + let(:dns_result) { [] } + let(:message) { MessageFactory.outgoing(server, domain: domain) } + + let(:smtp_client_server) { SMTPClient::Server.new("mx1.example.com") } + subject(:sender) { described_class.new("example.com", servers: [smtp_client_server]) } + + before do + allow(DNSResolver.local).to receive(:a).with("mx1.example.com").and_return(dns_result) + sender.start + end + + context "when there is no current endpoint to use" do + it "returns a SoftFail" do + result = sender.send_message(message) + expect(result).to be_a SendResult + expect(result).to have_attributes( + type: "SoftFail", + retry: true, + output: "", + details: /No SMTP servers were available for example.com. No hosts to try./, + connect_error: true + ) + end + end + + context "when there is an endpoint" do + let(:dns_result) { ["1.2.3.4"] } + + context "it sends the message to the endpoint" do + context "if the message is a bounce" do + let(:message) { MessageFactory.outgoing(server, domain: domain) { |m| m.bounce = true } } + + it "sends an empty MAIL FROM" do + sender.send_message(message) + expect(sender.endpoints.last).to have_received(:send_message).with( + kind_of(String), + "", + ["john@example.com"] + ) + end + end + + context "if the domain has a valid custom return path" do + let(:domain) { create(:domain, return_path_status: "OK") } + + it "sends the custom return path as MAIL FROM" do + sender.send_message(message) + expect(sender.endpoints.last).to have_received(:send_message).with( + kind_of(String), + "#{server.token}@#{domain.return_path_domain}", + ["john@example.com"] + ) + end + end + + context "if the domain has no valid custom return path" do + it "sends the server default return path as MAIL FROM" do + sender.send_message(message) + expect(sender.endpoints.last).to have_received(:send_message).with( + kind_of(String), + "#{server.token}@#{Postal::Config.dns.return_path_domain}", + ["john@example.com"] + ) + end + end + + context "if the sender has specified an RCPT TO" do + subject(:sender) { described_class.new("example.com", servers: [smtp_client_server], rcpt_to: "custom@example.com") } + + it "sends the specified RCPT TO" do + sender.send_message(message) + expect(sender.endpoints.last).to have_received(:send_message).with( + kind_of(String), + kind_of(String), + ["custom@example.com"] + ) + end + end + + context "if the sender has not specified an RCPT TO" do + it "uses the RCPT TO from the message" do + sender.send_message(message) + expect(sender.endpoints.last).to have_received(:send_message).with( + kind_of(String), + kind_of(String), + ["john@example.com"] + ) + end + end + + context "if the configuration says to add the Resent-Sender header" do + it "adds the resent-sender header" do + sender.send_message(message) + expect(sender.endpoints.last).to have_received(:send_message).with( + "Resent-Sender: #{server.token}@#{Postal::Config.dns.return_path_domain}\r\n#{message.raw_message}", + kind_of(String), + kind_of(Array) + ) + end + end + + context "if the configuration says to not add the Resent-From header" do + before do + allow(Postal::Config.postal).to receive(:use_resent_sender_header?).and_return(false) + end + + it "does not add the resent-from header" do + sender.send_message(message) + expect(sender.endpoints.last).to have_received(:send_message).with( + message.raw_message, + kind_of(String), + kind_of(Array) + ) + end + end + end + + context "when the message is accepted" do + it "returns a Sent result" do + result = sender.send_message(message) + expect(result).to be_a SendResult + expect(result).to have_attributes( + type: "Sent", + details: "Message for john@example.com accepted by 1.2.3.4:25 (mx1.example.com)", + output: "accepted" + ) + end + end + + context "when SMTP server is busy" do + let(:smtp_send_message_error) { proc { Net::SMTPServerBusy.new("SMTP server was busy") } } + + it "returns a SoftFail" do + result = sender.send_message(message) + expect(result).to be_a SendResult + expect(result).to have_attributes( + type: "SoftFail", + retry: true, + details: /Temporary SMTP delivery error when sending/ + ) + end + + it "resets the endpoint SMTP sesssion" do + sender.send_message(message) + expect(sender.endpoints.last).to have_received(:reset_smtp_session) + end + end + + context "when the SMTP server returns an error if a retry time in seconds" do + let(:smtp_send_message_error) { proc { Net::SMTPServerBusy.new("Try again in 30 seconds") } } + + it "returns a SoftFail with the retry time from the error" do + result = sender.send_message(message) + expect(result).to be_a SendResult + expect(result).to have_attributes( + type: "SoftFail", + retry: 40 + ) + end + end + + context "when the SMTP server returns an error if a retry time in minutes" do + let(:smtp_send_message_error) { proc { Net::SMTPServerBusy.new("Try again in 5 minutes") } } + + it "returns a SoftFail with the retry time from the error" do + result = sender.send_message(message) + expect(result).to be_a SendResult + expect(result).to have_attributes( + type: "SoftFail", + retry: 310 + ) + end + end + + context "when there is an SMTP authentication error" do + let(:smtp_send_message_error) { proc { Net::SMTPAuthenticationError.new("Denied") } } + + it "returns a SoftFail" do + result = sender.send_message(message) + expect(result).to be_a SendResult + expect(result).to have_attributes( + type: "SoftFail", + details: /Temporary SMTP delivery error when sending/ + ) + end + + it "resets the endpoint SMTP sesssion" do + sender.send_message(message) + expect(sender.endpoints.last).to have_received(:reset_smtp_session) + end + end + + context "when there is a timeout" do + let(:smtp_send_message_error) { proc { Net::ReadTimeout.new } } + + it "returns a SoftFail" do + result = sender.send_message(message) + expect(result).to be_a SendResult + expect(result).to have_attributes( + type: "SoftFail", + details: /Temporary SMTP delivery error when sending/ + ) + end + + it "resets the endpoint SMTP sesssion" do + sender.send_message(message) + expect(sender.endpoints.last).to have_received(:reset_smtp_session) + end + end + + context "when there is an SMTP syntax error" do + let(:smtp_send_message_error) { proc { Net::SMTPSyntaxError.new("Syntax error") } } + + it "returns a SoftFail" do + result = sender.send_message(message) + expect(result).to be_a SendResult + expect(result).to have_attributes( + type: "SoftFail", + output: "Syntax error", + details: /Temporary SMTP delivery error when sending/ + ) + end + + it "resets the endpoint SMTP sesssion" do + sender.send_message(message) + expect(sender.endpoints.last).to have_received(:reset_smtp_session) + end + end + + context "when there is an unknown SMTP error" do + let(:smtp_send_message_error) { proc { Net::SMTPUnknownError.new("unknown error") } } + + it "returns a SoftFail" do + result = sender.send_message(message) + expect(result).to be_a SendResult + expect(result).to have_attributes( + type: "SoftFail", + output: "unknown error", + details: /Temporary SMTP delivery error when sending/ + ) + end + + it "resets the endpoint SMTP sesssion" do + sender.send_message(message) + expect(sender.endpoints.last).to have_received(:reset_smtp_session) + end + end + + context "when there is an fatal SMTP error" do + let(:smtp_send_message_error) { proc { Net::SMTPFatalError.new("fatal error") } } + + it "returns a HardFail" do + result = sender.send_message(message) + expect(result).to be_a SendResult + expect(result).to have_attributes( + type: "HardFail", + output: "fatal error", + details: /Permanent SMTP delivery error when sending/ + ) + end + + it "resets the endpoint SMTP sesssion" do + sender.send_message(message) + expect(sender.endpoints.last).to have_received(:reset_smtp_session) + end + end + + context "when there is an unexpected error" do + let(:smtp_send_message_error) { proc { ZeroDivisionError.new("divided by 0") } } + + it "returns a SoftFail" do + result = sender.send_message(message) + expect(result).to be_a SendResult + expect(result).to have_attributes( + type: "SoftFail", + output: "divided by 0", + details: /An error occurred while sending the message/ + ) + end + + it "resets the endpoint SMTP sesssion" do + sender.send_message(message) + expect(sender.endpoints.last).to have_received(:reset_smtp_session) + end + end + end + end + + describe "#finish" do + let(:server) { SMTPClient::Server.new("custom.example.com") } + + subject(:sender) { described_class.new("example.com", servers: [server]) } + + let(:smtp_start_error) do + proc do |endpoint| + Errno::ECONNREFUSED if endpoint.ip_address == "1.2.3.4" + end + end + + before do + allow(DNSResolver.local).to receive(:a).with("custom.example.com").and_return(["1.2.3.4", "2.3.4.5"]) + sender.start + end + + it "calls finish_smtp_session on all endpoints" do + sender.finish + expect(sender.endpoints.size).to eq 2 + expect(sender.endpoints).to all have_received(:finish_smtp_session).at_least(:once) + end + end +end