1
0
مراية لـ https://github.com/postalserver/postal.git تم المزامنة 2025-12-01 05:43:04 +00:00

refactor: refactor the SMTP sender

هذا الالتزام موجود في:
Adam Cooke
2024-02-29 10:32:57 +00:00
الأصل be0df7b463
التزام 633c509a45
11 ملفات معدلة مع 1291 إضافات و256 حذوفات

عرض الملف

@@ -132,7 +132,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.6.3)
json (2.7.1)
kaminari (1.2.2)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2)
@@ -145,7 +145,7 @@ GEM
activerecord
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)
json
rouge (>= 3.30, < 5.0)

عرض الملف

@@ -75,17 +75,15 @@ class DNSResolver
# @return [Array<String>]
def effective_ns(name)
records = []
dns do |dns|
parts = name.split(".")
(parts.size - 1).times do |n|
d = parts[n, parts.size - n + 1].join(".")
parts = name.split(".")
(parts.size - 1).times do |n|
d = parts[n, parts.size - n + 1].join(".")
records = get_resources(d, Resolv::DNS::Resource::IN::NS).map do |s|
s.name.to_s
end
break if records.present?
records = get_resources(d, Resolv::DNS::Resource::IN::NS).map do |s|
s.name.to_s
end
break if records.present?
end
records

عرض الملف

@@ -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

عرض الملف

@@ -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

عرض الملف

@@ -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
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()
@domain = domain
@source_ip_address = source_ip_address
@options = options
@smtp_client = nil
@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 = []
@hostnames = []
@log_id = Nifty::Utils::RandomString.generate(length: 8).upcase
# 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|
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
server.endpoints.each do |endpoint|
result = connect_to_endpoint(endpoint)
return endpoint if result
end
end
@connection_errors
false
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
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
@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])
hostnames = @endpoints.map { |e| e.server.hostname }.uniq
r.details += " Tried #{hostnames.to_sentence}."
end
rescue Errno::ECONNRESET, Errno::EPIPE, OpenSSL::SSL::SSLError
raise unless (tries += 1) < 2
reconnect
retry
r.output = @connection_errors.join(", ")
r.connect_error = true
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
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
log "Finishing up"
@smtp_client&.finish
@endpoints.each(&:finish_smtp_session)
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
# 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
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
def log(text)
Postal.logger.info text, id: @log_id, component: "smtp-sender"
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
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)
# 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
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
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
# 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")
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|
relays.map do |relay|
next unless relay.host.present?
{
hostname: relay.host,
port: relay.port,
ssl_mode: relay.ssl_mode
}
SMTPClient::Server.new(relay.host, relay.port, ssl_mode: relay.ssl_mode)
end.compact
hosts.empty? ? nil : hosts
@smtp_relays = hosts.empty? ? nil : hosts
end
end

عرض الملف

@@ -21,6 +21,7 @@ ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.acronym "API"
inflect.acronym "DNS"
inflect.acronym "SSL"
inflect.acronym "MySQL"
inflect.acronym "DB"

عرض الملف

@@ -6,6 +6,8 @@ module Net
attr_accessor :source_address
def secure_socket?
return false unless @socket
@socket.io.is_a?(OpenSSL::SSL::SSLSocket)
end

عرض الملف

@@ -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

عرض الملف

@@ -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

عرض الملف

@@ -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