From 73a55a5053b871cd0ca923aace208617342c5f55 Mon Sep 17 00:00:00 2001 From: Adam Cooke Date: Thu, 22 Feb 2024 22:33:30 +0000 Subject: [PATCH] refactor: move senders in to app/senders/ --- app/lib/dns_resolver.rb | 2 + .../incoming_message_processor.rb | 6 +- .../outgoing_message_processor.rb | 2 +- app/senders/base_sender.rb | 14 + app/senders/http_sender.rb | 134 ++++++++ app/senders/send_result.rb | 20 ++ app/senders/smtp_sender.rb | 291 +++++++++++++++++ lib/postal/http_sender.rb | 136 -------- lib/postal/send_result.rb | 22 -- lib/postal/sender.rb | 16 - lib/postal/smtp_sender.rb | 295 ------------------ .../incoming_message_processor_spec.rb | 18 +- .../outgoing_message_processor_spec.rb | 8 +- spec/lib/message_dequeuer/state_spec.rb | 14 +- 14 files changed, 485 insertions(+), 493 deletions(-) create mode 100644 app/senders/base_sender.rb create mode 100644 app/senders/http_sender.rb create mode 100644 app/senders/send_result.rb create mode 100644 app/senders/smtp_sender.rb delete mode 100644 lib/postal/http_sender.rb delete mode 100644 lib/postal/send_result.rb delete mode 100644 lib/postal/sender.rb delete mode 100644 lib/postal/smtp_sender.rb diff --git a/app/lib/dns_resolver.rb b/app/lib/dns_resolver.rb index 0526584..366ddb1 100644 --- a/app/lib/dns_resolver.rb +++ b/app/lib/dns_resolver.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "resolv" + class DNSResolver attr_reader :nameservers diff --git a/app/lib/message_dequeuer/incoming_message_processor.rb b/app/lib/message_dequeuer/incoming_message_processor.rb index d42a1dc..0d1881b 100644 --- a/app/lib/message_dequeuer/incoming_message_processor.rb +++ b/app/lib/message_dequeuer/incoming_message_processor.rb @@ -163,11 +163,11 @@ module MessageDequeuer case queued_message.message.endpoint when SMTPEndpoint - sender = @state.sender_for(Postal::SMTPSender, queued_message.message.recipient_domain, nil, servers: [queued_message.message.endpoint]) + sender = @state.sender_for(SMTPSender, queued_message.message.recipient_domain, nil, servers: [queued_message.message.endpoint]) when HTTPEndpoint - sender = @state.sender_for(Postal::HTTPSender, queued_message.message.endpoint) + sender = @state.sender_for(HTTPSender, queued_message.message.endpoint) when AddressEndpoint - sender = @state.sender_for(Postal::SMTPSender, queued_message.message.endpoint.domain, nil, force_rcpt_to: queued_message.message.endpoint.address) + sender = @state.sender_for(SMTPSender, queued_message.message.endpoint.domain, nil, force_rcpt_to: queued_message.message.endpoint.address) else log "invalid endpoint for route (#{queued_message.message.endpoint_type})" create_delivery "HardFail", details: "Invalid endpoint for route." diff --git a/app/lib/message_dequeuer/outgoing_message_processor.rb b/app/lib/message_dequeuer/outgoing_message_processor.rb index af27c8f..e930b33 100644 --- a/app/lib/message_dequeuer/outgoing_message_processor.rb +++ b/app/lib/message_dequeuer/outgoing_message_processor.rb @@ -135,7 +135,7 @@ module MessageDequeuer @result = @state.send_result return if @result - sender = @state.sender_for(Postal::SMTPSender, + sender = @state.sender_for(SMTPSender, queued_message.message.recipient_domain, queued_message.ip_address) diff --git a/app/senders/base_sender.rb b/app/senders/base_sender.rb new file mode 100644 index 0000000..a009e9f --- /dev/null +++ b/app/senders/base_sender.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class BaseSender + + def start + end + + def send_message(message) + end + + def finish + end + +end diff --git a/app/senders/http_sender.rb b/app/senders/http_sender.rb new file mode 100644 index 0000000..af931e9 --- /dev/null +++ b/app/senders/http_sender.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +class HTTPSender < BaseSender + + def initialize(endpoint, options = {}) + super() + @endpoint = endpoint + @options = options + @log_id = Nifty::Utils::RandomString.generate(length: 8).upcase + end + + def send_message(message) + start_time = Time.now + result = SendResult.new + result.log_id = @log_id + + request_options = {} + request_options[:sign] = true + request_options[:timeout] = @endpoint.timeout || 5 + case @endpoint.encoding + when "BodyAsJSON" + request_options[:json] = parameters(message, flat: false).to_json + when "FormData" + request_options[:params] = parameters(message, flat: true) + end + + log "Sending request to #{@endpoint.url}" + response = Postal::HTTP.post(@endpoint.url, request_options) + result.secure = !!response[:secure] # rubocop:disable Style/DoubleNegation + result.details = "Received a #{response[:code]} from #{@endpoint.url}" + log " -> Received: #{response[:code]}" + if response[:body] + log " -> Body: #{response[:body][0, 255]}" + result.output = response[:body].to_s[0, 500].strip + end + if response[:code] >= 200 && response[:code] < 300 + # This is considered a success + result.type = "Sent" + elsif response[:code] >= 500 && response[:code] < 600 + # This is temporary. They might fix their server so it should soft fail. + result.type = "SoftFail" + result.retry = true + elsif response[:code].negative? + # Connection/SSL etc... errors + result.type = "SoftFail" + result.retry = true + result.connect_error = true + elsif response[:code] == 429 + # Rate limit exceeded, treat as a hard fail and don't send bounces + result.type = "HardFail" + result.suppress_bounce = true + else + # This is permanent. Any other error isn't cool with us. + result.type = "HardFail" + end + result.time = (Time.now - start_time).to_f.round(2) + result + end + + private + + def log(text) + Postal.logger.info text, id: @log_id, component: "http-sender" + end + + def parameters(message, options = {}) + case @endpoint.format + when "Hash" + hash = { + id: message.id, + rcpt_to: message.rcpt_to, + mail_from: message.mail_from, + token: message.token, + subject: message.subject, + message_id: message.message_id, + timestamp: message.timestamp.to_f, + size: message.size, + spam_status: message.spam_status, + bounce: message.bounce, + received_with_ssl: message.received_with_ssl, + to: message.headers["to"]&.last, + cc: message.headers["cc"]&.last, + from: message.headers["from"]&.last, + date: message.headers["date"]&.last, + in_reply_to: message.headers["in-reply-to"]&.last, + references: message.headers["references"]&.last, + html_body: message.html_body, + attachment_quantity: message.attachments.size, + auto_submitted: message.headers["auto-submitted"]&.last, + reply_to: message.headers["reply-to"] + } + + if @endpoint.strip_replies + hash[:plain_body], hash[:replies_from_plain_body] = Postal::ReplySeparator.separate(message.plain_body) + else + hash[:plain_body] = message.plain_body + end + + if @endpoint.include_attachments? + if options[:flat] + message.attachments.each_with_index do |a, i| + hash["attachments[#{i}][filename]"] = a.filename + hash["attachments[#{i}][content_type]"] = a.content_type + hash["attachments[#{i}][size]"] = a.body.to_s.bytesize.to_s + hash["attachments[#{i}][data]"] = Base64.encode64(a.body.to_s) + end + else + hash[:attachments] = message.attachments.map do |a| + { + filename: a.filename, + content_type: a.mime_type, + size: a.body.to_s.bytesize, + data: Base64.encode64(a.body.to_s) + } + end + end + end + + hash + when "RawMessage" + { + id: message.id, + rcpt_to: message.rcpt_to, + mail_from: message.mail_from, + message: Base64.encode64(message.raw_message), + base64: true, + size: message.size.to_i + } + else + {} + end + end + +end diff --git a/app/senders/send_result.rb b/app/senders/send_result.rb new file mode 100644 index 0000000..c8a6353 --- /dev/null +++ b/app/senders/send_result.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class SendResult + + attr_accessor :type + attr_accessor :details + attr_accessor :retry + attr_accessor :output + attr_accessor :secure + attr_accessor :connect_error + attr_accessor :log_id + attr_accessor :time + attr_accessor :suppress_bounce + + def initialize + @details = "" + yield self if block_given? + end + +end diff --git a/app/senders/smtp_sender.rb b/app/senders/smtp_sender.rb new file mode 100644 index 0000000..bb6c124 --- /dev/null +++ b/app/senders/smtp_sender.rb @@ -0,0 +1,291 @@ +# frozen_string_literal: true + +class SMTPSender < BaseSender + + def initialize(domain, source_ip_address, options = {}) + super() + @domain = domain + @source_ip_address = source_ip_address + @options = options + @smtp_client = nil + @connection_errors = [] + @hostnames = [] + @log_id = Nifty::Utils::RandomString.generate(length: 8).upcase + end + + def start + 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 + end + end + + @connection_errors + 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}" + end + if Postal.config.general.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 + 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]) + end + rescue Errno::ECONNRESET, Errno::EPIPE, OpenSSL::SSL::SSLError + raise unless (tries += 1) < 2 + + reconnect + retry + 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 + end + + def finish + log "Finishing up" + @smtp_client&.finish + 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 + end + mx_servers + end + end + + def log(text) + Postal.logger.info text, id: @log_id, component: "smtp-sender" + end + + 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) + end + records.first&.to_s&.downcase + 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 + + 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.dns.smtp_server_hostname || "localhost" + end + + def relay_hosts + hosts = Postal.config.smtp_relays.map do |relay| + next unless relay.hostname.present? + + { + hostname: relay.hostname, + port: relay.port, + ssl_mode: relay.ssl_mode + } + end.compact + hosts.empty? ? nil : hosts + end + + end + +end diff --git a/lib/postal/http_sender.rb b/lib/postal/http_sender.rb deleted file mode 100644 index 69011dd..0000000 --- a/lib/postal/http_sender.rb +++ /dev/null @@ -1,136 +0,0 @@ -# frozen_string_literal: true - -module Postal - class HTTPSender < Sender - - def initialize(endpoint, options = {}) - super() - @endpoint = endpoint - @options = options - @log_id = Nifty::Utils::RandomString.generate(length: 8).upcase - end - - def send_message(message) - start_time = Time.now - result = SendResult.new - result.log_id = @log_id - - request_options = {} - request_options[:sign] = true - request_options[:timeout] = @endpoint.timeout || 5 - case @endpoint.encoding - when "BodyAsJSON" - request_options[:json] = parameters(message, flat: false).to_json - when "FormData" - request_options[:params] = parameters(message, flat: true) - end - - log "Sending request to #{@endpoint.url}" - response = Postal::HTTP.post(@endpoint.url, request_options) - result.secure = !!response[:secure] # rubocop:disable Style/DoubleNegation - result.details = "Received a #{response[:code]} from #{@endpoint.url}" - log " -> Received: #{response[:code]}" - if response[:body] - log " -> Body: #{response[:body][0, 255]}" - result.output = response[:body].to_s[0, 500].strip - end - if response[:code] >= 200 && response[:code] < 300 - # This is considered a success - result.type = "Sent" - elsif response[:code] >= 500 && response[:code] < 600 - # This is temporary. They might fix their server so it should soft fail. - result.type = "SoftFail" - result.retry = true - elsif response[:code].negative? - # Connection/SSL etc... errors - result.type = "SoftFail" - result.retry = true - result.connect_error = true - elsif response[:code] == 429 - # Rate limit exceeded, treat as a hard fail and don't send bounces - result.type = "HardFail" - result.suppress_bounce = true - else - # This is permanent. Any other error isn't cool with us. - result.type = "HardFail" - end - result.time = (Time.now - start_time).to_f.round(2) - result - end - - private - - def log(text) - Postal.logger.info text, id: @log_id, component: "http-sender" - end - - def parameters(message, options = {}) - case @endpoint.format - when "Hash" - hash = { - id: message.id, - rcpt_to: message.rcpt_to, - mail_from: message.mail_from, - token: message.token, - subject: message.subject, - message_id: message.message_id, - timestamp: message.timestamp.to_f, - size: message.size, - spam_status: message.spam_status, - bounce: message.bounce, - received_with_ssl: message.received_with_ssl, - to: message.headers["to"]&.last, - cc: message.headers["cc"]&.last, - from: message.headers["from"]&.last, - date: message.headers["date"]&.last, - in_reply_to: message.headers["in-reply-to"]&.last, - references: message.headers["references"]&.last, - html_body: message.html_body, - attachment_quantity: message.attachments.size, - auto_submitted: message.headers["auto-submitted"]&.last, - reply_to: message.headers["reply-to"] - } - - if @endpoint.strip_replies - hash[:plain_body], hash[:replies_from_plain_body] = Postal::ReplySeparator.separate(message.plain_body) - else - hash[:plain_body] = message.plain_body - end - - if @endpoint.include_attachments? - if options[:flat] - message.attachments.each_with_index do |a, i| - hash["attachments[#{i}][filename]"] = a.filename - hash["attachments[#{i}][content_type]"] = a.content_type - hash["attachments[#{i}][size]"] = a.body.to_s.bytesize.to_s - hash["attachments[#{i}][data]"] = Base64.encode64(a.body.to_s) - end - else - hash[:attachments] = message.attachments.map do |a| - { - filename: a.filename, - content_type: a.mime_type, - size: a.body.to_s.bytesize, - data: Base64.encode64(a.body.to_s) - } - end - end - end - - hash - when "RawMessage" - { - id: message.id, - rcpt_to: message.rcpt_to, - mail_from: message.mail_from, - message: Base64.encode64(message.raw_message), - base64: true, - size: message.size.to_i - } - else - {} - end - end - - end -end diff --git a/lib/postal/send_result.rb b/lib/postal/send_result.rb deleted file mode 100644 index b0505ba..0000000 --- a/lib/postal/send_result.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Postal - class SendResult - - attr_accessor :type - attr_accessor :details - attr_accessor :retry - attr_accessor :output - attr_accessor :secure - attr_accessor :connect_error - attr_accessor :log_id - attr_accessor :time - attr_accessor :suppress_bounce - - def initialize - @details = "" - yield self if block_given? - end - - end -end diff --git a/lib/postal/sender.rb b/lib/postal/sender.rb deleted file mode 100644 index ff5cd49..0000000 --- a/lib/postal/sender.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -module Postal - class Sender - - def start - end - - def send_message(message) - end - - def finish - end - - end -end diff --git a/lib/postal/smtp_sender.rb b/lib/postal/smtp_sender.rb deleted file mode 100644 index 2857c30..0000000 --- a/lib/postal/smtp_sender.rb +++ /dev/null @@ -1,295 +0,0 @@ -# frozen_string_literal: true - -require "resolv" - -module Postal - class SMTPSender < Sender - - def initialize(domain, source_ip_address, options = {}) - super() - @domain = domain - @source_ip_address = source_ip_address - @options = options - @smtp_client = nil - @connection_errors = [] - @hostnames = [] - @log_id = Nifty::Utils::RandomString.generate(length: 8).upcase - end - - def start - 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 - end - end - - @connection_errors - 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}" - end - if Postal.config.general.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 - 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]) - end - rescue Errno::ECONNRESET, Errno::EPIPE, OpenSSL::SSL::SSLError - raise unless (tries += 1) < 2 - - reconnect - retry - 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 - end - - def finish - log "Finishing up" - @smtp_client&.finish - 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 - end - mx_servers - end - end - - def log(text) - Postal.logger.info text, id: @log_id, component: "smtp-sender" - end - - 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) - end - records.first&.to_s&.downcase - 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 - - 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.dns.smtp_server_hostname || "localhost" - end - - def relay_hosts - hosts = Postal.config.smtp_relays.map do |relay| - next unless relay.hostname.present? - - { - hostname: relay.hostname, - port: relay.port, - ssl_mode: relay.ssl_mode - } - end.compact - hosts.empty? ? nil : hosts - end - - end - - end -end diff --git a/spec/lib/message_dequeuer/incoming_message_processor_spec.rb b/spec/lib/message_dequeuer/incoming_message_processor_spec.rb index 5ac111a..9cd5f81 100644 --- a/spec/lib/message_dequeuer/incoming_message_processor_spec.rb +++ b/spec/lib/message_dequeuer/incoming_message_processor_spec.rb @@ -409,8 +409,8 @@ module MessageDequeuer it "gets a sender from the state and sends the message to it" do http_sender_double = double("HTTPSender") - expect(http_sender_double).to receive(:send_message).with(queued_message.message).and_return(Postal::SendResult.new) - expect(state).to receive(:sender_for).with(Postal::HTTPSender, endpoint).and_return(http_sender_double) + expect(http_sender_double).to receive(:send_message).with(queued_message.message).and_return(SendResult.new) + expect(state).to receive(:sender_for).with(HTTPSender, endpoint).and_return(http_sender_double) processor.process end end @@ -421,8 +421,8 @@ module MessageDequeuer it "gets a sender from the state and sends the message to it" do smtp_sender_double = double("SMTPSender") - expect(smtp_sender_double).to receive(:send_message).with(queued_message.message).and_return(Postal::SendResult.new) - expect(state).to receive(:sender_for).with(Postal::SMTPSender, message.recipient_domain, nil, { servers: [endpoint] }).and_return(smtp_sender_double) + expect(smtp_sender_double).to receive(:send_message).with(queued_message.message).and_return(SendResult.new) + expect(state).to receive(:sender_for).with(SMTPSender, message.recipient_domain, nil, { servers: [endpoint] }).and_return(smtp_sender_double) processor.process end end @@ -433,8 +433,8 @@ module MessageDequeuer it "gets a sender from the state and sends the message to it" do smtp_sender_double = double("SMTPSender") - expect(smtp_sender_double).to receive(:send_message).with(queued_message.message).and_return(Postal::SendResult.new) - expect(state).to receive(:sender_for).with(Postal::SMTPSender, endpoint.domain, nil, { force_rcpt_to: endpoint.address }).and_return(smtp_sender_double) + expect(smtp_sender_double).to receive(:send_message).with(queued_message.message).and_return(SendResult.new) + expect(state).to receive(:sender_for).with(SMTPSender, endpoint.domain, nil, { force_rcpt_to: endpoint.address }).and_return(smtp_sender_double) processor.process end end @@ -469,7 +469,7 @@ module MessageDequeuer let(:route) { create(:route, server: server, mode: "Endpoint", endpoint: endpoint) } let(:send_result) do - Postal::SendResult.new do |result| + SendResult.new do |result| result.type = "Sent" result.details = "Sent successfully" end @@ -477,7 +477,7 @@ module MessageDequeuer before do smtp_sender_mock = double("SMTPSender") - allow(Postal::SMTPSender).to receive(:new).and_return(smtp_sender_mock) + allow(SMTPSender).to receive(:new).and_return(smtp_sender_mock) allow(smtp_sender_mock).to receive(:start) allow(smtp_sender_mock).to receive(:finish) allow(smtp_sender_mock).to receive(:send_message).and_return(send_result) @@ -611,7 +611,7 @@ module MessageDequeuer before do smtp_sender_mock = double("SMTPSender") - allow(Postal::SMTPSender).to receive(:new).and_return(smtp_sender_mock) + allow(SMTPSender).to receive(:new).and_return(smtp_sender_mock) allow(smtp_sender_mock).to receive(:start) allow(smtp_sender_mock).to receive(:finish) allow(smtp_sender_mock).to receive(:send_message) do diff --git a/spec/lib/message_dequeuer/outgoing_message_processor_spec.rb b/spec/lib/message_dequeuer/outgoing_message_processor_spec.rb index a455175..e070a17 100644 --- a/spec/lib/message_dequeuer/outgoing_message_processor_spec.rb +++ b/spec/lib/message_dequeuer/outgoing_message_processor_spec.rb @@ -361,7 +361,7 @@ module MessageDequeuer context "when there are no other impediments" do let(:send_result) do - Postal::SendResult.new do |r| + SendResult.new do |r| r.type = "Sent" end end @@ -383,7 +383,7 @@ module MessageDequeuer it "gets a sender from the state and sends the message to it" do mocked_sender = double("SMTPSender") expect(mocked_sender).to receive(:send_message).with(queued_message.message).and_return(send_result) - expect(state).to receive(:sender_for).with(Postal::SMTPSender, message.recipient_domain, ip).and_return(mocked_sender) + expect(state).to receive(:sender_for).with(SMTPSender, message.recipient_domain, ip).and_return(mocked_sender) processor.process end @@ -393,7 +393,7 @@ module MessageDequeuer it "gets a sender from the state and sends the message to it" do mocked_sender = double("SMTPSender") expect(mocked_sender).to receive(:send_message).with(queued_message.message).and_return(send_result) - expect(state).to receive(:sender_for).with(Postal::SMTPSender, message.recipient_domain, nil).and_return(mocked_sender) + expect(state).to receive(:sender_for).with(SMTPSender, message.recipient_domain, nil).and_return(mocked_sender) processor.process end @@ -514,7 +514,7 @@ module MessageDequeuer context "when an exception occurrs during processing" do before do smtp_sender_mock = double("SMTPSender") - allow(Postal::SMTPSender).to receive(:new).and_return(smtp_sender_mock) + allow(SMTPSender).to receive(:new).and_return(smtp_sender_mock) allow(smtp_sender_mock).to receive(:start) allow(smtp_sender_mock).to receive(:send_message) do 1 / 0 diff --git a/spec/lib/message_dequeuer/state_spec.rb b/spec/lib/message_dequeuer/state_spec.rb index 3bcb608..49ca96b 100644 --- a/spec/lib/message_dequeuer/state_spec.rb +++ b/spec/lib/message_dequeuer/state_spec.rb @@ -9,7 +9,7 @@ module MessageDequeuer describe "#send_result" do it "can be get and set" do - result = instance_double(Postal::SendResult) + result = instance_double(SendResult) state.send_result = result expect(state.send_result).to be result end @@ -17,20 +17,20 @@ module MessageDequeuer describe "#sender_for" do it "returns a instance of the given sender initialized with the args" do - sender = state.sender_for(Postal::HTTPSender, "1234") - expect(sender).to be_a Postal::HTTPSender + sender = state.sender_for(HTTPSender, "1234") + expect(sender).to be_a HTTPSender end it "returns a cached sender on subsequent calls" do - sender = state.sender_for(Postal::HTTPSender, "1234") - expect(state.sender_for(Postal::HTTPSender, "1234")).to be sender + sender = state.sender_for(HTTPSender, "1234") + expect(state.sender_for(HTTPSender, "1234")).to be sender end end describe "#finished" do it "calls finish on all cached senders" do - sender1 = state.sender_for(Postal::HTTPSender, "1234") - sender2 = state.sender_for(Postal::HTTPSender, "4444") + sender1 = state.sender_for(HTTPSender, "1234") + sender2 = state.sender_for(HTTPSender, "4444") expect(sender1).to receive(:finish) expect(sender2).to receive(:finish)