مراية لـ
https://github.com/postalserver/postal.git
تم المزامنة 2025-11-30 21:32:30 +00:00
This commit also adds some of tests for the Domain model. It was during the writing of these tests that the DNS resolution refactoring requirement became apparent.
296 أسطر
9.2 KiB
Ruby
296 أسطر
9.2 KiB
Ruby
# 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
|