# frozen_string_literal: true class SMTPSender < BaseSender 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 @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 = [] # 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| server.endpoints.each do |endpoint| result = connect_to_endpoint(endpoint) return endpoint if result end end false end 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 hostnames = @endpoints.map { |e| e.server.hostname }.uniq r.details += " Tried #{hostnames.to_sentence}." end r.output = @connection_errors.join(", ") r.connect_error = true end end 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 @endpoints.each(&:finish_smtp_session) end private # 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 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 # 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 # 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 "#{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, raise_timeout_errors: true).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 # 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") relays = Postal::Config.postal.smtp_relays return nil if relays.nil? relays.map do |relay| next unless relay.host.present? SMTPClient::Server.new(relay.host, relay.port, ssl_mode: relay.ssl_mode) end.compact @smtp_relays = relays.empty? ? nil : relays end end end