# 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_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 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.postal.smtp_hostname || "localhost" end def relay_hosts relays = Postal::Config.postal.smtp_relays return nil if relays.nil? hosts = relays.map do |relay| next unless relay.host.present? { hostname: relay.host, port: relay.port, ssl_mode: relay.ssl_mode } end.compact hosts.empty? ? nil : hosts end end end