مراية لـ
https://github.com/postalserver/postal.git
تم المزامنة 2026-01-17 05:33:35 +00:00
Merge pull request #2829 from postalserver/refactor-smtp-sender
Refactor SMTP Sender
هذا الالتزام موجود في:
@@ -36,7 +36,7 @@ RUN gem install bundler -v 2.5.6 --no-doc
|
|||||||
# Install the latest and active gem dependencies and re-run
|
# Install the latest and active gem dependencies and re-run
|
||||||
# the appropriate commands to handle installs.
|
# the appropriate commands to handle installs.
|
||||||
COPY --chown=postal Gemfile Gemfile.lock ./
|
COPY --chown=postal Gemfile Gemfile.lock ./
|
||||||
RUN bundle install -j 6
|
RUN bundle install -j 4
|
||||||
|
|
||||||
# Copy the application (and set permissions)
|
# Copy the application (and set permissions)
|
||||||
COPY ./docker/wait-for.sh /docker-entrypoint.sh
|
COPY ./docker/wait-for.sh /docker-entrypoint.sh
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ GEM
|
|||||||
rails-dom-testing (>= 1, < 3)
|
rails-dom-testing (>= 1, < 3)
|
||||||
railties (>= 4.2.0)
|
railties (>= 4.2.0)
|
||||||
thor (>= 0.14, < 2.0)
|
thor (>= 0.14, < 2.0)
|
||||||
json (2.6.3)
|
json (2.7.1)
|
||||||
kaminari (1.2.2)
|
kaminari (1.2.2)
|
||||||
activesupport (>= 4.1.0)
|
activesupport (>= 4.1.0)
|
||||||
kaminari-actionview (= 1.2.2)
|
kaminari-actionview (= 1.2.2)
|
||||||
@@ -145,7 +145,7 @@ GEM
|
|||||||
activerecord
|
activerecord
|
||||||
kaminari-core (= 1.2.2)
|
kaminari-core (= 1.2.2)
|
||||||
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)
|
concurrent-ruby (>= 1.0, < 2.0)
|
||||||
json
|
json
|
||||||
rouge (>= 3.30, < 5.0)
|
rouge (>= 3.30, < 5.0)
|
||||||
|
|||||||
@@ -75,17 +75,15 @@ class DNSResolver
|
|||||||
# @return [Array<String>]
|
# @return [Array<String>]
|
||||||
def effective_ns(name)
|
def effective_ns(name)
|
||||||
records = []
|
records = []
|
||||||
dns do |dns|
|
parts = name.split(".")
|
||||||
parts = name.split(".")
|
(parts.size - 1).times do |n|
|
||||||
(parts.size - 1).times do |n|
|
d = parts[n, parts.size - n + 1].join(".")
|
||||||
d = parts[n, parts.size - n + 1].join(".")
|
|
||||||
|
|
||||||
records = get_resources(d, Resolv::DNS::Resource::IN::NS).map do |s|
|
records = get_resources(d, Resolv::DNS::Resource::IN::NS).map do |s|
|
||||||
s.name.to_s
|
s.name.to_s
|
||||||
end
|
|
||||||
|
|
||||||
break if records.present?
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
break if records.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
records
|
records
|
||||||
|
|||||||
169
app/lib/smtp_client/endpoint.rb
Normal file
169
app/lib/smtp_client/endpoint.rb
Normal file
@@ -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
|
||||||
35
app/lib/smtp_client/server.rb
Normal file
35
app/lib/smtp_client/server.rb
Normal file
@@ -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<SMTPClient::Endpoint>]
|
||||||
|
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
|
||||||
12
app/lib/smtp_client/ssl_modes.rb
Normal file
12
app/lib/smtp_client/ssl_modes.rb
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module SMTPClient
|
||||||
|
module SSLModes
|
||||||
|
|
||||||
|
AUTO = "Auto"
|
||||||
|
STARTTLS = "STARTLS"
|
||||||
|
TLS = "TLS"
|
||||||
|
NONE = "None"
|
||||||
|
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -2,293 +2,255 @@
|
|||||||
|
|
||||||
class SMTPSender < BaseSender
|
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()
|
super()
|
||||||
@domain = domain
|
@domain = domain
|
||||||
@source_ip_address = source_ip_address
|
@source_ip_address = source_ip_address
|
||||||
@options = options
|
@rcpt_to = rcpt_to
|
||||||
@smtp_client = nil
|
|
||||||
|
# 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 = []
|
@connection_errors = []
|
||||||
@hostnames = []
|
# Stores all endpoints that we have attempted to deliver mail to
|
||||||
@log_id = Nifty::Utils::RandomString.generate(length: 8).upcase
|
@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
|
end
|
||||||
|
|
||||||
def start
|
def start
|
||||||
|
servers = @servers || self.class.smtp_relays || resolve_mx_records_for_domain || []
|
||||||
|
|
||||||
servers.each do |server|
|
servers.each do |server|
|
||||||
if server.is_a?(SMTPEndpoint)
|
server.endpoints.each do |endpoint|
|
||||||
hostname = server.hostname
|
result = connect_to_endpoint(endpoint)
|
||||||
port = server.port || 25
|
return endpoint if result
|
||||||
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
|
||||||
end
|
end
|
||||||
|
|
||||||
@connection_errors
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
def reconnect
|
def send_message(message)
|
||||||
log "Reconnecting"
|
# If we don't have a current endpoint than we should raise an error.
|
||||||
begin
|
if @current_endpoint.nil?
|
||||||
@smtp_client&.finish
|
return create_result("SoftFail") do |r|
|
||||||
rescue StandardError
|
r.retry = true
|
||||||
nil
|
r.details = "No SMTP servers were available for #{@domain}."
|
||||||
end
|
if @endpoints.empty?
|
||||||
start
|
r.details += " No hosts to try."
|
||||||
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
|
else
|
||||||
@smtp_client.rset_errors
|
hostnames = @endpoints.map { |e| e.server.hostname }.uniq
|
||||||
rcpt_to = force_rcpt_to || @options[:force_rcpt_to] || message.rcpt_to
|
r.details += " Tried #{hostnames.to_sentence}."
|
||||||
log "Sending message #{message.server.id}::#{message.id} to #{rcpt_to}"
|
|
||||||
smtp_result = @smtp_client.send_message(raw_message, mail_from, [rcpt_to])
|
|
||||||
end
|
end
|
||||||
rescue Errno::ECONNRESET, Errno::EPIPE, OpenSSL::SSL::SSLError
|
r.output = @connection_errors.join(", ")
|
||||||
raise unless (tries += 1) < 2
|
r.connect_error = true
|
||||||
|
|
||||||
reconnect
|
|
||||||
retry
|
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
result.time = (Time.now - start_time).to_f.round(2)
|
mail_from = determine_mail_from_for_message(message)
|
||||||
result
|
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
|
end
|
||||||
|
|
||||||
def finish
|
def finish
|
||||||
log "Finishing up"
|
@endpoints.each(&:finish_smtp_session)
|
||||||
@smtp_client&.finish
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def servers
|
# Take a message and attempt to send it to the SMTP server that we are
|
||||||
@options[:servers] || self.class.relay_hosts || @servers ||= begin
|
# currently connected to. If there is a connection error, we will just
|
||||||
mx_servers = DNSResolver.local.mx(@domain).map(&:last)
|
# reset the client and retry again once.
|
||||||
if mx_servers.empty?
|
#
|
||||||
mx_servers = [@domain] # This will be resolved to an A or AAAA record later
|
# @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
|
||||||
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
|
||||||
end
|
end
|
||||||
|
|
||||||
def log(text)
|
# Return the MAIL FROM which should be used for the given message
|
||||||
Postal.logger.info text, id: @log_id, component: "smtp-sender"
|
#
|
||||||
end
|
# @param message [MessageDB::Message]
|
||||||
|
# @return [String]
|
||||||
|
def determine_mail_from_for_message(message)
|
||||||
|
return "" if message.bounce
|
||||||
|
|
||||||
def destination_host_description
|
# If the domain has a valid custom return path configured, return
|
||||||
"#{@hostnames.last} (#{@remote_ip})"
|
# that.
|
||||||
end
|
if message.domain.return_path_status == "OK"
|
||||||
|
return "#{message.server.token}@#{message.domain.return_path_domain}"
|
||||||
def lookup_ip_address(type, hostname)
|
|
||||||
records = []
|
|
||||||
case type
|
|
||||||
when :a
|
|
||||||
records = DNSResolver.local.a(hostname)
|
|
||||||
when :aaaa
|
|
||||||
records = DNSResolver.local.aaaa(hostname)
|
|
||||||
end
|
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<String>]
|
||||||
|
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
|
end
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
|
|
||||||
def ssl_context_with_verify
|
# Return an array of SMTP relays as configured. Returns nil
|
||||||
@ssl_context_with_verify ||= begin
|
# if no SMTP relays are configured.
|
||||||
c = OpenSSL::SSL::SSLContext.new
|
#
|
||||||
c.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
def smtp_relays
|
||||||
c.cert_store = OpenSSL::X509::Store.new
|
return @smtp_relays if instance_variable_defined?("@smtp_relays")
|
||||||
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
|
relays = Postal::Config.postal.smtp_relays
|
||||||
return nil if relays.nil?
|
return nil if relays.nil?
|
||||||
|
|
||||||
hosts = relays.map do |relay|
|
relays.map do |relay|
|
||||||
next unless relay.host.present?
|
next unless relay.host.present?
|
||||||
|
|
||||||
{
|
SMTPClient::Server.new(relay.host, relay.port, ssl_mode: relay.ssl_mode)
|
||||||
hostname: relay.host,
|
|
||||||
port: relay.port,
|
|
||||||
ssl_mode: relay.ssl_mode
|
|
||||||
}
|
|
||||||
end.compact
|
end.compact
|
||||||
hosts.empty? ? nil : hosts
|
|
||||||
|
@smtp_relays = hosts.empty? ? nil : hosts
|
||||||
end
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ ActiveSupport::Inflector.inflections(:en) do |inflect|
|
|||||||
|
|
||||||
inflect.acronym "API"
|
inflect.acronym "API"
|
||||||
inflect.acronym "DNS"
|
inflect.acronym "DNS"
|
||||||
|
inflect.acronym "SSL"
|
||||||
inflect.acronym "MySQL"
|
inflect.acronym "MySQL"
|
||||||
|
|
||||||
inflect.acronym "DB"
|
inflect.acronym "DB"
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ module Net
|
|||||||
attr_accessor :source_address
|
attr_accessor :source_address
|
||||||
|
|
||||||
def secure_socket?
|
def secure_socket?
|
||||||
|
return false unless @socket
|
||||||
|
|
||||||
@socket.io.is_a?(OpenSSL::SSL::SSLSocket)
|
@socket.io.is_a?(OpenSSL::SSL::SSLSocket)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
293
spec/lib/smtp_client/endpoint_spec.rb
Normal file
293
spec/lib/smtp_client/endpoint_spec.rb
Normal file
@@ -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
|
||||||
67
spec/lib/smtp_client/server_spec.rb
Normal file
67
spec/lib/smtp_client/server_spec.rb
Normal file
@@ -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
|
||||||
496
spec/senders/smtp_sender_spec.rb
Normal file
496
spec/senders/smtp_sender_spec.rb
Normal file
@@ -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
|
||||||
المرجع في مشكلة جديدة
حظر مستخدم