مراية لـ
https://github.com/postalserver/postal.git
تم المزامنة 2025-11-30 21:32:30 +00:00
refactor: move senders in to app/senders/
هذا الالتزام موجود في:
@@ -1,5 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "resolv"
|
||||
|
||||
class DNSResolver
|
||||
|
||||
attr_reader :nameservers
|
||||
|
||||
@@ -163,11 +163,11 @@ module MessageDequeuer
|
||||
|
||||
case queued_message.message.endpoint
|
||||
when SMTPEndpoint
|
||||
sender = @state.sender_for(Postal::SMTPSender, queued_message.message.recipient_domain, nil, servers: [queued_message.message.endpoint])
|
||||
sender = @state.sender_for(SMTPSender, queued_message.message.recipient_domain, nil, servers: [queued_message.message.endpoint])
|
||||
when HTTPEndpoint
|
||||
sender = @state.sender_for(Postal::HTTPSender, queued_message.message.endpoint)
|
||||
sender = @state.sender_for(HTTPSender, queued_message.message.endpoint)
|
||||
when AddressEndpoint
|
||||
sender = @state.sender_for(Postal::SMTPSender, queued_message.message.endpoint.domain, nil, force_rcpt_to: queued_message.message.endpoint.address)
|
||||
sender = @state.sender_for(SMTPSender, queued_message.message.endpoint.domain, nil, force_rcpt_to: queued_message.message.endpoint.address)
|
||||
else
|
||||
log "invalid endpoint for route (#{queued_message.message.endpoint_type})"
|
||||
create_delivery "HardFail", details: "Invalid endpoint for route."
|
||||
|
||||
@@ -135,7 +135,7 @@ module MessageDequeuer
|
||||
@result = @state.send_result
|
||||
return if @result
|
||||
|
||||
sender = @state.sender_for(Postal::SMTPSender,
|
||||
sender = @state.sender_for(SMTPSender,
|
||||
queued_message.message.recipient_domain,
|
||||
queued_message.ip_address)
|
||||
|
||||
|
||||
14
app/senders/base_sender.rb
Normal file
14
app/senders/base_sender.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class BaseSender
|
||||
|
||||
def start
|
||||
end
|
||||
|
||||
def send_message(message)
|
||||
end
|
||||
|
||||
def finish
|
||||
end
|
||||
|
||||
end
|
||||
134
app/senders/http_sender.rb
Normal file
134
app/senders/http_sender.rb
Normal file
@@ -0,0 +1,134 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class HTTPSender < BaseSender
|
||||
|
||||
def initialize(endpoint, options = {})
|
||||
super()
|
||||
@endpoint = endpoint
|
||||
@options = options
|
||||
@log_id = Nifty::Utils::RandomString.generate(length: 8).upcase
|
||||
end
|
||||
|
||||
def send_message(message)
|
||||
start_time = Time.now
|
||||
result = SendResult.new
|
||||
result.log_id = @log_id
|
||||
|
||||
request_options = {}
|
||||
request_options[:sign] = true
|
||||
request_options[:timeout] = @endpoint.timeout || 5
|
||||
case @endpoint.encoding
|
||||
when "BodyAsJSON"
|
||||
request_options[:json] = parameters(message, flat: false).to_json
|
||||
when "FormData"
|
||||
request_options[:params] = parameters(message, flat: true)
|
||||
end
|
||||
|
||||
log "Sending request to #{@endpoint.url}"
|
||||
response = Postal::HTTP.post(@endpoint.url, request_options)
|
||||
result.secure = !!response[:secure] # rubocop:disable Style/DoubleNegation
|
||||
result.details = "Received a #{response[:code]} from #{@endpoint.url}"
|
||||
log " -> Received: #{response[:code]}"
|
||||
if response[:body]
|
||||
log " -> Body: #{response[:body][0, 255]}"
|
||||
result.output = response[:body].to_s[0, 500].strip
|
||||
end
|
||||
if response[:code] >= 200 && response[:code] < 300
|
||||
# This is considered a success
|
||||
result.type = "Sent"
|
||||
elsif response[:code] >= 500 && response[:code] < 600
|
||||
# This is temporary. They might fix their server so it should soft fail.
|
||||
result.type = "SoftFail"
|
||||
result.retry = true
|
||||
elsif response[:code].negative?
|
||||
# Connection/SSL etc... errors
|
||||
result.type = "SoftFail"
|
||||
result.retry = true
|
||||
result.connect_error = true
|
||||
elsif response[:code] == 429
|
||||
# Rate limit exceeded, treat as a hard fail and don't send bounces
|
||||
result.type = "HardFail"
|
||||
result.suppress_bounce = true
|
||||
else
|
||||
# This is permanent. Any other error isn't cool with us.
|
||||
result.type = "HardFail"
|
||||
end
|
||||
result.time = (Time.now - start_time).to_f.round(2)
|
||||
result
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def log(text)
|
||||
Postal.logger.info text, id: @log_id, component: "http-sender"
|
||||
end
|
||||
|
||||
def parameters(message, options = {})
|
||||
case @endpoint.format
|
||||
when "Hash"
|
||||
hash = {
|
||||
id: message.id,
|
||||
rcpt_to: message.rcpt_to,
|
||||
mail_from: message.mail_from,
|
||||
token: message.token,
|
||||
subject: message.subject,
|
||||
message_id: message.message_id,
|
||||
timestamp: message.timestamp.to_f,
|
||||
size: message.size,
|
||||
spam_status: message.spam_status,
|
||||
bounce: message.bounce,
|
||||
received_with_ssl: message.received_with_ssl,
|
||||
to: message.headers["to"]&.last,
|
||||
cc: message.headers["cc"]&.last,
|
||||
from: message.headers["from"]&.last,
|
||||
date: message.headers["date"]&.last,
|
||||
in_reply_to: message.headers["in-reply-to"]&.last,
|
||||
references: message.headers["references"]&.last,
|
||||
html_body: message.html_body,
|
||||
attachment_quantity: message.attachments.size,
|
||||
auto_submitted: message.headers["auto-submitted"]&.last,
|
||||
reply_to: message.headers["reply-to"]
|
||||
}
|
||||
|
||||
if @endpoint.strip_replies
|
||||
hash[:plain_body], hash[:replies_from_plain_body] = Postal::ReplySeparator.separate(message.plain_body)
|
||||
else
|
||||
hash[:plain_body] = message.plain_body
|
||||
end
|
||||
|
||||
if @endpoint.include_attachments?
|
||||
if options[:flat]
|
||||
message.attachments.each_with_index do |a, i|
|
||||
hash["attachments[#{i}][filename]"] = a.filename
|
||||
hash["attachments[#{i}][content_type]"] = a.content_type
|
||||
hash["attachments[#{i}][size]"] = a.body.to_s.bytesize.to_s
|
||||
hash["attachments[#{i}][data]"] = Base64.encode64(a.body.to_s)
|
||||
end
|
||||
else
|
||||
hash[:attachments] = message.attachments.map do |a|
|
||||
{
|
||||
filename: a.filename,
|
||||
content_type: a.mime_type,
|
||||
size: a.body.to_s.bytesize,
|
||||
data: Base64.encode64(a.body.to_s)
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
hash
|
||||
when "RawMessage"
|
||||
{
|
||||
id: message.id,
|
||||
rcpt_to: message.rcpt_to,
|
||||
mail_from: message.mail_from,
|
||||
message: Base64.encode64(message.raw_message),
|
||||
base64: true,
|
||||
size: message.size.to_i
|
||||
}
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
20
app/senders/send_result.rb
Normal file
20
app/senders/send_result.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SendResult
|
||||
|
||||
attr_accessor :type
|
||||
attr_accessor :details
|
||||
attr_accessor :retry
|
||||
attr_accessor :output
|
||||
attr_accessor :secure
|
||||
attr_accessor :connect_error
|
||||
attr_accessor :log_id
|
||||
attr_accessor :time
|
||||
attr_accessor :suppress_bounce
|
||||
|
||||
def initialize
|
||||
@details = ""
|
||||
yield self if block_given?
|
||||
end
|
||||
|
||||
end
|
||||
291
app/senders/smtp_sender.rb
Normal file
291
app/senders/smtp_sender.rb
Normal file
@@ -0,0 +1,291 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SMTPSender < BaseSender
|
||||
|
||||
def initialize(domain, source_ip_address, options = {})
|
||||
super()
|
||||
@domain = domain
|
||||
@source_ip_address = source_ip_address
|
||||
@options = options
|
||||
@smtp_client = nil
|
||||
@connection_errors = []
|
||||
@hostnames = []
|
||||
@log_id = Nifty::Utils::RandomString.generate(length: 8).upcase
|
||||
end
|
||||
|
||||
def start
|
||||
servers.each do |server|
|
||||
if server.is_a?(SMTPEndpoint)
|
||||
hostname = server.hostname
|
||||
port = server.port || 25
|
||||
ssl_mode = server.ssl_mode
|
||||
elsif server.is_a?(Hash)
|
||||
hostname = server[:hostname]
|
||||
port = server[:port] || 25
|
||||
ssl_mode = server[:ssl_mode] || "Auto"
|
||||
else
|
||||
hostname = server
|
||||
port = 25
|
||||
ssl_mode = "Auto"
|
||||
end
|
||||
|
||||
@hostnames << hostname
|
||||
[:aaaa, :a].each do |ip_type|
|
||||
if @source_ip_address && @source_ip_address.ipv6.blank? && ip_type == :aaaa
|
||||
# Don't try to use IPv6 if the IP address we're sending from doesn't support it.
|
||||
next
|
||||
end
|
||||
|
||||
begin
|
||||
@remote_ip = lookup_ip_address(ip_type, hostname)
|
||||
if @remote_ip.nil?
|
||||
if ip_type == :a
|
||||
# As we can't resolve the last IP, we'll put this
|
||||
@connection_errors << "Could not resolve #{hostname}"
|
||||
end
|
||||
next
|
||||
end
|
||||
|
||||
smtp_client = Net::SMTP.new(@remote_ip, port)
|
||||
smtp_client.open_timeout = Postal.config.smtp_client.open_timeout
|
||||
smtp_client.read_timeout = Postal.config.smtp_client.read_timeout
|
||||
smtp_client.tls_hostname = hostname
|
||||
|
||||
if @source_ip_address
|
||||
# Set the source IP as appropriate
|
||||
smtp_client.source_address = ip_type == :aaaa ? @source_ip_address.ipv6 : @source_ip_address.ipv4
|
||||
end
|
||||
|
||||
case ssl_mode
|
||||
when "Auto"
|
||||
smtp_client.enable_starttls_auto(self.class.ssl_context_without_verify)
|
||||
when "STARTTLS"
|
||||
smtp_client.enable_starttls(self.class.ssl_context_with_verify)
|
||||
when "TLS"
|
||||
smtp_client.enable_tls(self.class.ssl_context_with_verify)
|
||||
else
|
||||
smtp_client.disable_starttls
|
||||
smtp_client.disable_tls
|
||||
end
|
||||
|
||||
smtp_client.start(@source_ip_address ? @source_ip_address.hostname : self.class.default_helo_hostname)
|
||||
log "Connected to #{@remote_ip}:#{port} (#{hostname})"
|
||||
rescue StandardError => e
|
||||
if e.is_a?(OpenSSL::SSL::SSLError) && ssl_mode == "Auto"
|
||||
log "SSL error (#{e.message}), retrying without SSL"
|
||||
ssl_mode = nil
|
||||
retry
|
||||
end
|
||||
|
||||
log "Cannot connect to #{@remote_ip}:#{port} (#{hostname}) (#{e.class}: #{e.message})"
|
||||
@connection_errors << e.message unless @connection_errors.include?(e.message)
|
||||
begin
|
||||
smtp_client.finish
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
smtp_client = nil
|
||||
end
|
||||
|
||||
if smtp_client
|
||||
@smtp_client = smtp_client
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@connection_errors
|
||||
end
|
||||
|
||||
def reconnect
|
||||
log "Reconnecting"
|
||||
begin
|
||||
@smtp_client&.finish
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
start
|
||||
end
|
||||
|
||||
def safe_rset
|
||||
# Something went wrong sending the last email. Reset the connection if possible, else disconnect.
|
||||
|
||||
@smtp_client.rset
|
||||
rescue StandardError
|
||||
# Don't reconnect, this would be rather rude if we don't have any more emails to send.
|
||||
begin
|
||||
@smtp_client.finish
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def send_message(message, force_rcpt_to = nil)
|
||||
start_time = Time.now
|
||||
result = SendResult.new
|
||||
result.log_id = @log_id
|
||||
if @smtp_client && !@smtp_client.started?
|
||||
# For some reason we had an SMTP connection but it's no longer connected.
|
||||
# Make a new one.
|
||||
start
|
||||
end
|
||||
|
||||
if @smtp_client
|
||||
result.secure = @smtp_client.secure_socket?
|
||||
end
|
||||
|
||||
begin
|
||||
if message.bounce
|
||||
mail_from = ""
|
||||
elsif message.domain.return_path_status == "OK"
|
||||
mail_from = "#{message.server.token}@#{message.domain.return_path_domain}"
|
||||
else
|
||||
mail_from = "#{message.server.token}@#{Postal.config.dns.return_path}"
|
||||
end
|
||||
if Postal.config.general.use_resent_sender_header
|
||||
raw_message = "Resent-Sender: #{mail_from}\r\n" + message.raw_message
|
||||
else
|
||||
raw_message = message.raw_message
|
||||
end
|
||||
tries = 0
|
||||
begin
|
||||
if @smtp_client.nil?
|
||||
log "-> No SMTP server available for #{@domain}"
|
||||
log "-> Hostnames: #{@hostnames.inspect}"
|
||||
log "-> Errors: #{@connection_errors.inspect}"
|
||||
result.type = "SoftFail"
|
||||
result.retry = true
|
||||
result.details = "No SMTP servers were available for #{@domain}. Tried #{@hostnames.to_sentence}"
|
||||
result.output = @connection_errors.join(", ")
|
||||
result.connect_error = true
|
||||
return result
|
||||
else
|
||||
@smtp_client.rset_errors
|
||||
rcpt_to = force_rcpt_to || @options[:force_rcpt_to] || message.rcpt_to
|
||||
log "Sending message #{message.server.id}::#{message.id} to #{rcpt_to}"
|
||||
smtp_result = @smtp_client.send_message(raw_message, mail_from, [rcpt_to])
|
||||
end
|
||||
rescue Errno::ECONNRESET, Errno::EPIPE, OpenSSL::SSL::SSLError
|
||||
raise unless (tries += 1) < 2
|
||||
|
||||
reconnect
|
||||
retry
|
||||
end
|
||||
result.type = "Sent"
|
||||
result.details = "Message for #{rcpt_to} accepted by #{destination_host_description}"
|
||||
if @smtp_client.source_address
|
||||
result.details += " (from #{@smtp_client.source_address})"
|
||||
end
|
||||
result.output = smtp_result.string
|
||||
log "Message sent ##{message.id} to #{destination_host_description} for #{rcpt_to}"
|
||||
rescue Net::SMTPServerBusy, Net::SMTPAuthenticationError, Net::SMTPSyntaxError, Net::SMTPUnknownError, Net::ReadTimeout => e
|
||||
log "#{e.class}: #{e.message}"
|
||||
result.type = "SoftFail"
|
||||
result.retry = true
|
||||
result.details = "Temporary SMTP delivery error when sending to #{destination_host_description}"
|
||||
result.output = e.message
|
||||
if e.to_s =~ /(\d+) seconds/
|
||||
result.retry = ::Regexp.last_match(1).to_i + 10
|
||||
elsif e.to_s =~ /(\d+) minutes/
|
||||
result.retry = (::Regexp.last_match(1).to_i * 60) + 10
|
||||
end
|
||||
|
||||
safe_rset
|
||||
rescue Net::SMTPFatalError => e
|
||||
log "#{e.class}: #{e.message}"
|
||||
result.type = "HardFail"
|
||||
result.details = "Permanent SMTP delivery error when sending to #{destination_host_description}"
|
||||
result.output = e.message
|
||||
safe_rset
|
||||
rescue StandardError => e
|
||||
log "#{e.class}: #{e.message}"
|
||||
if defined?(Sentry)
|
||||
Sentry.capture_exception(e, extra: { log_id: @log_id, server_id: message.server.id, message_id: message.id })
|
||||
end
|
||||
result.type = "SoftFail"
|
||||
result.retry = true
|
||||
result.details = "An error occurred while sending the message to #{destination_host_description}"
|
||||
result.output = e.message
|
||||
safe_rset
|
||||
end
|
||||
|
||||
result.time = (Time.now - start_time).to_f.round(2)
|
||||
result
|
||||
end
|
||||
|
||||
def finish
|
||||
log "Finishing up"
|
||||
@smtp_client&.finish
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def servers
|
||||
@options[:servers] || self.class.relay_hosts || @servers ||= begin
|
||||
mx_servers = DNSResolver.local.mx(@domain).map(&:last)
|
||||
if mx_servers.empty?
|
||||
mx_servers = [@domain] # This will be resolved to an A or AAAA record later
|
||||
end
|
||||
mx_servers
|
||||
end
|
||||
end
|
||||
|
||||
def log(text)
|
||||
Postal.logger.info text, id: @log_id, component: "smtp-sender"
|
||||
end
|
||||
|
||||
def destination_host_description
|
||||
"#{@hostnames.last} (#{@remote_ip})"
|
||||
end
|
||||
|
||||
def lookup_ip_address(type, hostname)
|
||||
records = []
|
||||
case type
|
||||
when :a
|
||||
records = DNSResolver.local.a(hostname)
|
||||
when :aaaa
|
||||
records = DNSResolver.local.aaaa(hostname)
|
||||
end
|
||||
records.first&.to_s&.downcase
|
||||
end
|
||||
|
||||
class << self
|
||||
|
||||
def ssl_context_with_verify
|
||||
@ssl_context_with_verify ||= begin
|
||||
c = OpenSSL::SSL::SSLContext.new
|
||||
c.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
||||
c.cert_store = OpenSSL::X509::Store.new
|
||||
c.cert_store.set_default_paths
|
||||
c
|
||||
end
|
||||
end
|
||||
|
||||
def ssl_context_without_verify
|
||||
@ssl_context_without_verify ||= begin
|
||||
c = OpenSSL::SSL::SSLContext.new
|
||||
c.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
||||
c
|
||||
end
|
||||
end
|
||||
|
||||
def default_helo_hostname
|
||||
Postal.config.dns.helo_hostname || Postal.config.dns.smtp_server_hostname || "localhost"
|
||||
end
|
||||
|
||||
def relay_hosts
|
||||
hosts = Postal.config.smtp_relays.map do |relay|
|
||||
next unless relay.hostname.present?
|
||||
|
||||
{
|
||||
hostname: relay.hostname,
|
||||
port: relay.port,
|
||||
ssl_mode: relay.ssl_mode
|
||||
}
|
||||
end.compact
|
||||
hosts.empty? ? nil : hosts
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
@@ -1,136 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Postal
|
||||
class HTTPSender < Sender
|
||||
|
||||
def initialize(endpoint, options = {})
|
||||
super()
|
||||
@endpoint = endpoint
|
||||
@options = options
|
||||
@log_id = Nifty::Utils::RandomString.generate(length: 8).upcase
|
||||
end
|
||||
|
||||
def send_message(message)
|
||||
start_time = Time.now
|
||||
result = SendResult.new
|
||||
result.log_id = @log_id
|
||||
|
||||
request_options = {}
|
||||
request_options[:sign] = true
|
||||
request_options[:timeout] = @endpoint.timeout || 5
|
||||
case @endpoint.encoding
|
||||
when "BodyAsJSON"
|
||||
request_options[:json] = parameters(message, flat: false).to_json
|
||||
when "FormData"
|
||||
request_options[:params] = parameters(message, flat: true)
|
||||
end
|
||||
|
||||
log "Sending request to #{@endpoint.url}"
|
||||
response = Postal::HTTP.post(@endpoint.url, request_options)
|
||||
result.secure = !!response[:secure] # rubocop:disable Style/DoubleNegation
|
||||
result.details = "Received a #{response[:code]} from #{@endpoint.url}"
|
||||
log " -> Received: #{response[:code]}"
|
||||
if response[:body]
|
||||
log " -> Body: #{response[:body][0, 255]}"
|
||||
result.output = response[:body].to_s[0, 500].strip
|
||||
end
|
||||
if response[:code] >= 200 && response[:code] < 300
|
||||
# This is considered a success
|
||||
result.type = "Sent"
|
||||
elsif response[:code] >= 500 && response[:code] < 600
|
||||
# This is temporary. They might fix their server so it should soft fail.
|
||||
result.type = "SoftFail"
|
||||
result.retry = true
|
||||
elsif response[:code].negative?
|
||||
# Connection/SSL etc... errors
|
||||
result.type = "SoftFail"
|
||||
result.retry = true
|
||||
result.connect_error = true
|
||||
elsif response[:code] == 429
|
||||
# Rate limit exceeded, treat as a hard fail and don't send bounces
|
||||
result.type = "HardFail"
|
||||
result.suppress_bounce = true
|
||||
else
|
||||
# This is permanent. Any other error isn't cool with us.
|
||||
result.type = "HardFail"
|
||||
end
|
||||
result.time = (Time.now - start_time).to_f.round(2)
|
||||
result
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def log(text)
|
||||
Postal.logger.info text, id: @log_id, component: "http-sender"
|
||||
end
|
||||
|
||||
def parameters(message, options = {})
|
||||
case @endpoint.format
|
||||
when "Hash"
|
||||
hash = {
|
||||
id: message.id,
|
||||
rcpt_to: message.rcpt_to,
|
||||
mail_from: message.mail_from,
|
||||
token: message.token,
|
||||
subject: message.subject,
|
||||
message_id: message.message_id,
|
||||
timestamp: message.timestamp.to_f,
|
||||
size: message.size,
|
||||
spam_status: message.spam_status,
|
||||
bounce: message.bounce,
|
||||
received_with_ssl: message.received_with_ssl,
|
||||
to: message.headers["to"]&.last,
|
||||
cc: message.headers["cc"]&.last,
|
||||
from: message.headers["from"]&.last,
|
||||
date: message.headers["date"]&.last,
|
||||
in_reply_to: message.headers["in-reply-to"]&.last,
|
||||
references: message.headers["references"]&.last,
|
||||
html_body: message.html_body,
|
||||
attachment_quantity: message.attachments.size,
|
||||
auto_submitted: message.headers["auto-submitted"]&.last,
|
||||
reply_to: message.headers["reply-to"]
|
||||
}
|
||||
|
||||
if @endpoint.strip_replies
|
||||
hash[:plain_body], hash[:replies_from_plain_body] = Postal::ReplySeparator.separate(message.plain_body)
|
||||
else
|
||||
hash[:plain_body] = message.plain_body
|
||||
end
|
||||
|
||||
if @endpoint.include_attachments?
|
||||
if options[:flat]
|
||||
message.attachments.each_with_index do |a, i|
|
||||
hash["attachments[#{i}][filename]"] = a.filename
|
||||
hash["attachments[#{i}][content_type]"] = a.content_type
|
||||
hash["attachments[#{i}][size]"] = a.body.to_s.bytesize.to_s
|
||||
hash["attachments[#{i}][data]"] = Base64.encode64(a.body.to_s)
|
||||
end
|
||||
else
|
||||
hash[:attachments] = message.attachments.map do |a|
|
||||
{
|
||||
filename: a.filename,
|
||||
content_type: a.mime_type,
|
||||
size: a.body.to_s.bytesize,
|
||||
data: Base64.encode64(a.body.to_s)
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
hash
|
||||
when "RawMessage"
|
||||
{
|
||||
id: message.id,
|
||||
rcpt_to: message.rcpt_to,
|
||||
mail_from: message.mail_from,
|
||||
message: Base64.encode64(message.raw_message),
|
||||
base64: true,
|
||||
size: message.size.to_i
|
||||
}
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -1,22 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Postal
|
||||
class SendResult
|
||||
|
||||
attr_accessor :type
|
||||
attr_accessor :details
|
||||
attr_accessor :retry
|
||||
attr_accessor :output
|
||||
attr_accessor :secure
|
||||
attr_accessor :connect_error
|
||||
attr_accessor :log_id
|
||||
attr_accessor :time
|
||||
attr_accessor :suppress_bounce
|
||||
|
||||
def initialize
|
||||
@details = ""
|
||||
yield self if block_given?
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -1,16 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Postal
|
||||
class Sender
|
||||
|
||||
def start
|
||||
end
|
||||
|
||||
def send_message(message)
|
||||
end
|
||||
|
||||
def finish
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -1,295 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "resolv"
|
||||
|
||||
module Postal
|
||||
class SMTPSender < Sender
|
||||
|
||||
def initialize(domain, source_ip_address, options = {})
|
||||
super()
|
||||
@domain = domain
|
||||
@source_ip_address = source_ip_address
|
||||
@options = options
|
||||
@smtp_client = nil
|
||||
@connection_errors = []
|
||||
@hostnames = []
|
||||
@log_id = Nifty::Utils::RandomString.generate(length: 8).upcase
|
||||
end
|
||||
|
||||
def start
|
||||
servers.each do |server|
|
||||
if server.is_a?(SMTPEndpoint)
|
||||
hostname = server.hostname
|
||||
port = server.port || 25
|
||||
ssl_mode = server.ssl_mode
|
||||
elsif server.is_a?(Hash)
|
||||
hostname = server[:hostname]
|
||||
port = server[:port] || 25
|
||||
ssl_mode = server[:ssl_mode] || "Auto"
|
||||
else
|
||||
hostname = server
|
||||
port = 25
|
||||
ssl_mode = "Auto"
|
||||
end
|
||||
|
||||
@hostnames << hostname
|
||||
[:aaaa, :a].each do |ip_type|
|
||||
if @source_ip_address && @source_ip_address.ipv6.blank? && ip_type == :aaaa
|
||||
# Don't try to use IPv6 if the IP address we're sending from doesn't support it.
|
||||
next
|
||||
end
|
||||
|
||||
begin
|
||||
@remote_ip = lookup_ip_address(ip_type, hostname)
|
||||
if @remote_ip.nil?
|
||||
if ip_type == :a
|
||||
# As we can't resolve the last IP, we'll put this
|
||||
@connection_errors << "Could not resolve #{hostname}"
|
||||
end
|
||||
next
|
||||
end
|
||||
|
||||
smtp_client = Net::SMTP.new(@remote_ip, port)
|
||||
smtp_client.open_timeout = Postal.config.smtp_client.open_timeout
|
||||
smtp_client.read_timeout = Postal.config.smtp_client.read_timeout
|
||||
smtp_client.tls_hostname = hostname
|
||||
|
||||
if @source_ip_address
|
||||
# Set the source IP as appropriate
|
||||
smtp_client.source_address = ip_type == :aaaa ? @source_ip_address.ipv6 : @source_ip_address.ipv4
|
||||
end
|
||||
|
||||
case ssl_mode
|
||||
when "Auto"
|
||||
smtp_client.enable_starttls_auto(self.class.ssl_context_without_verify)
|
||||
when "STARTTLS"
|
||||
smtp_client.enable_starttls(self.class.ssl_context_with_verify)
|
||||
when "TLS"
|
||||
smtp_client.enable_tls(self.class.ssl_context_with_verify)
|
||||
else
|
||||
smtp_client.disable_starttls
|
||||
smtp_client.disable_tls
|
||||
end
|
||||
|
||||
smtp_client.start(@source_ip_address ? @source_ip_address.hostname : self.class.default_helo_hostname)
|
||||
log "Connected to #{@remote_ip}:#{port} (#{hostname})"
|
||||
rescue StandardError => e
|
||||
if e.is_a?(OpenSSL::SSL::SSLError) && ssl_mode == "Auto"
|
||||
log "SSL error (#{e.message}), retrying without SSL"
|
||||
ssl_mode = nil
|
||||
retry
|
||||
end
|
||||
|
||||
log "Cannot connect to #{@remote_ip}:#{port} (#{hostname}) (#{e.class}: #{e.message})"
|
||||
@connection_errors << e.message unless @connection_errors.include?(e.message)
|
||||
begin
|
||||
smtp_client.finish
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
smtp_client = nil
|
||||
end
|
||||
|
||||
if smtp_client
|
||||
@smtp_client = smtp_client
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@connection_errors
|
||||
end
|
||||
|
||||
def reconnect
|
||||
log "Reconnecting"
|
||||
begin
|
||||
@smtp_client&.finish
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
start
|
||||
end
|
||||
|
||||
def safe_rset
|
||||
# Something went wrong sending the last email. Reset the connection if possible, else disconnect.
|
||||
|
||||
@smtp_client.rset
|
||||
rescue StandardError
|
||||
# Don't reconnect, this would be rather rude if we don't have any more emails to send.
|
||||
begin
|
||||
@smtp_client.finish
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def send_message(message, force_rcpt_to = nil)
|
||||
start_time = Time.now
|
||||
result = SendResult.new
|
||||
result.log_id = @log_id
|
||||
if @smtp_client && !@smtp_client.started?
|
||||
# For some reason we had an SMTP connection but it's no longer connected.
|
||||
# Make a new one.
|
||||
start
|
||||
end
|
||||
|
||||
if @smtp_client
|
||||
result.secure = @smtp_client.secure_socket?
|
||||
end
|
||||
|
||||
begin
|
||||
if message.bounce
|
||||
mail_from = ""
|
||||
elsif message.domain.return_path_status == "OK"
|
||||
mail_from = "#{message.server.token}@#{message.domain.return_path_domain}"
|
||||
else
|
||||
mail_from = "#{message.server.token}@#{Postal.config.dns.return_path}"
|
||||
end
|
||||
if Postal.config.general.use_resent_sender_header
|
||||
raw_message = "Resent-Sender: #{mail_from}\r\n" + message.raw_message
|
||||
else
|
||||
raw_message = message.raw_message
|
||||
end
|
||||
tries = 0
|
||||
begin
|
||||
if @smtp_client.nil?
|
||||
log "-> No SMTP server available for #{@domain}"
|
||||
log "-> Hostnames: #{@hostnames.inspect}"
|
||||
log "-> Errors: #{@connection_errors.inspect}"
|
||||
result.type = "SoftFail"
|
||||
result.retry = true
|
||||
result.details = "No SMTP servers were available for #{@domain}. Tried #{@hostnames.to_sentence}"
|
||||
result.output = @connection_errors.join(", ")
|
||||
result.connect_error = true
|
||||
return result
|
||||
else
|
||||
@smtp_client.rset_errors
|
||||
rcpt_to = force_rcpt_to || @options[:force_rcpt_to] || message.rcpt_to
|
||||
log "Sending message #{message.server.id}::#{message.id} to #{rcpt_to}"
|
||||
smtp_result = @smtp_client.send_message(raw_message, mail_from, [rcpt_to])
|
||||
end
|
||||
rescue Errno::ECONNRESET, Errno::EPIPE, OpenSSL::SSL::SSLError
|
||||
raise unless (tries += 1) < 2
|
||||
|
||||
reconnect
|
||||
retry
|
||||
end
|
||||
result.type = "Sent"
|
||||
result.details = "Message for #{rcpt_to} accepted by #{destination_host_description}"
|
||||
if @smtp_client.source_address
|
||||
result.details += " (from #{@smtp_client.source_address})"
|
||||
end
|
||||
result.output = smtp_result.string
|
||||
log "Message sent ##{message.id} to #{destination_host_description} for #{rcpt_to}"
|
||||
rescue Net::SMTPServerBusy, Net::SMTPAuthenticationError, Net::SMTPSyntaxError, Net::SMTPUnknownError, Net::ReadTimeout => e
|
||||
log "#{e.class}: #{e.message}"
|
||||
result.type = "SoftFail"
|
||||
result.retry = true
|
||||
result.details = "Temporary SMTP delivery error when sending to #{destination_host_description}"
|
||||
result.output = e.message
|
||||
if e.to_s =~ /(\d+) seconds/
|
||||
result.retry = ::Regexp.last_match(1).to_i + 10
|
||||
elsif e.to_s =~ /(\d+) minutes/
|
||||
result.retry = (::Regexp.last_match(1).to_i * 60) + 10
|
||||
end
|
||||
|
||||
safe_rset
|
||||
rescue Net::SMTPFatalError => e
|
||||
log "#{e.class}: #{e.message}"
|
||||
result.type = "HardFail"
|
||||
result.details = "Permanent SMTP delivery error when sending to #{destination_host_description}"
|
||||
result.output = e.message
|
||||
safe_rset
|
||||
rescue StandardError => e
|
||||
log "#{e.class}: #{e.message}"
|
||||
if defined?(Sentry)
|
||||
Sentry.capture_exception(e, extra: { log_id: @log_id, server_id: message.server.id, message_id: message.id })
|
||||
end
|
||||
result.type = "SoftFail"
|
||||
result.retry = true
|
||||
result.details = "An error occurred while sending the message to #{destination_host_description}"
|
||||
result.output = e.message
|
||||
safe_rset
|
||||
end
|
||||
|
||||
result.time = (Time.now - start_time).to_f.round(2)
|
||||
result
|
||||
end
|
||||
|
||||
def finish
|
||||
log "Finishing up"
|
||||
@smtp_client&.finish
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def servers
|
||||
@options[:servers] || self.class.relay_hosts || @servers ||= begin
|
||||
mx_servers = DNSResolver.local.mx(@domain).map(&:last)
|
||||
if mx_servers.empty?
|
||||
mx_servers = [@domain] # This will be resolved to an A or AAAA record later
|
||||
end
|
||||
mx_servers
|
||||
end
|
||||
end
|
||||
|
||||
def log(text)
|
||||
Postal.logger.info text, id: @log_id, component: "smtp-sender"
|
||||
end
|
||||
|
||||
def destination_host_description
|
||||
"#{@hostnames.last} (#{@remote_ip})"
|
||||
end
|
||||
|
||||
def lookup_ip_address(type, hostname)
|
||||
records = []
|
||||
case type
|
||||
when :a
|
||||
records = DNSResolver.local.a(hostname)
|
||||
when :aaaa
|
||||
records = DNSResolver.local.aaaa(hostname)
|
||||
end
|
||||
records.first&.to_s&.downcase
|
||||
end
|
||||
|
||||
class << self
|
||||
|
||||
def ssl_context_with_verify
|
||||
@ssl_context_with_verify ||= begin
|
||||
c = OpenSSL::SSL::SSLContext.new
|
||||
c.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
||||
c.cert_store = OpenSSL::X509::Store.new
|
||||
c.cert_store.set_default_paths
|
||||
c
|
||||
end
|
||||
end
|
||||
|
||||
def ssl_context_without_verify
|
||||
@ssl_context_without_verify ||= begin
|
||||
c = OpenSSL::SSL::SSLContext.new
|
||||
c.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
||||
c
|
||||
end
|
||||
end
|
||||
|
||||
def default_helo_hostname
|
||||
Postal.config.dns.helo_hostname || Postal.config.dns.smtp_server_hostname || "localhost"
|
||||
end
|
||||
|
||||
def relay_hosts
|
||||
hosts = Postal.config.smtp_relays.map do |relay|
|
||||
next unless relay.hostname.present?
|
||||
|
||||
{
|
||||
hostname: relay.hostname,
|
||||
port: relay.port,
|
||||
ssl_mode: relay.ssl_mode
|
||||
}
|
||||
end.compact
|
||||
hosts.empty? ? nil : hosts
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -409,8 +409,8 @@ module MessageDequeuer
|
||||
|
||||
it "gets a sender from the state and sends the message to it" do
|
||||
http_sender_double = double("HTTPSender")
|
||||
expect(http_sender_double).to receive(:send_message).with(queued_message.message).and_return(Postal::SendResult.new)
|
||||
expect(state).to receive(:sender_for).with(Postal::HTTPSender, endpoint).and_return(http_sender_double)
|
||||
expect(http_sender_double).to receive(:send_message).with(queued_message.message).and_return(SendResult.new)
|
||||
expect(state).to receive(:sender_for).with(HTTPSender, endpoint).and_return(http_sender_double)
|
||||
processor.process
|
||||
end
|
||||
end
|
||||
@@ -421,8 +421,8 @@ module MessageDequeuer
|
||||
|
||||
it "gets a sender from the state and sends the message to it" do
|
||||
smtp_sender_double = double("SMTPSender")
|
||||
expect(smtp_sender_double).to receive(:send_message).with(queued_message.message).and_return(Postal::SendResult.new)
|
||||
expect(state).to receive(:sender_for).with(Postal::SMTPSender, message.recipient_domain, nil, { servers: [endpoint] }).and_return(smtp_sender_double)
|
||||
expect(smtp_sender_double).to receive(:send_message).with(queued_message.message).and_return(SendResult.new)
|
||||
expect(state).to receive(:sender_for).with(SMTPSender, message.recipient_domain, nil, { servers: [endpoint] }).and_return(smtp_sender_double)
|
||||
processor.process
|
||||
end
|
||||
end
|
||||
@@ -433,8 +433,8 @@ module MessageDequeuer
|
||||
|
||||
it "gets a sender from the state and sends the message to it" do
|
||||
smtp_sender_double = double("SMTPSender")
|
||||
expect(smtp_sender_double).to receive(:send_message).with(queued_message.message).and_return(Postal::SendResult.new)
|
||||
expect(state).to receive(:sender_for).with(Postal::SMTPSender, endpoint.domain, nil, { force_rcpt_to: endpoint.address }).and_return(smtp_sender_double)
|
||||
expect(smtp_sender_double).to receive(:send_message).with(queued_message.message).and_return(SendResult.new)
|
||||
expect(state).to receive(:sender_for).with(SMTPSender, endpoint.domain, nil, { force_rcpt_to: endpoint.address }).and_return(smtp_sender_double)
|
||||
processor.process
|
||||
end
|
||||
end
|
||||
@@ -469,7 +469,7 @@ module MessageDequeuer
|
||||
let(:route) { create(:route, server: server, mode: "Endpoint", endpoint: endpoint) }
|
||||
|
||||
let(:send_result) do
|
||||
Postal::SendResult.new do |result|
|
||||
SendResult.new do |result|
|
||||
result.type = "Sent"
|
||||
result.details = "Sent successfully"
|
||||
end
|
||||
@@ -477,7 +477,7 @@ module MessageDequeuer
|
||||
|
||||
before do
|
||||
smtp_sender_mock = double("SMTPSender")
|
||||
allow(Postal::SMTPSender).to receive(:new).and_return(smtp_sender_mock)
|
||||
allow(SMTPSender).to receive(:new).and_return(smtp_sender_mock)
|
||||
allow(smtp_sender_mock).to receive(:start)
|
||||
allow(smtp_sender_mock).to receive(:finish)
|
||||
allow(smtp_sender_mock).to receive(:send_message).and_return(send_result)
|
||||
@@ -611,7 +611,7 @@ module MessageDequeuer
|
||||
|
||||
before do
|
||||
smtp_sender_mock = double("SMTPSender")
|
||||
allow(Postal::SMTPSender).to receive(:new).and_return(smtp_sender_mock)
|
||||
allow(SMTPSender).to receive(:new).and_return(smtp_sender_mock)
|
||||
allow(smtp_sender_mock).to receive(:start)
|
||||
allow(smtp_sender_mock).to receive(:finish)
|
||||
allow(smtp_sender_mock).to receive(:send_message) do
|
||||
|
||||
@@ -361,7 +361,7 @@ module MessageDequeuer
|
||||
|
||||
context "when there are no other impediments" do
|
||||
let(:send_result) do
|
||||
Postal::SendResult.new do |r|
|
||||
SendResult.new do |r|
|
||||
r.type = "Sent"
|
||||
end
|
||||
end
|
||||
@@ -383,7 +383,7 @@ module MessageDequeuer
|
||||
it "gets a sender from the state and sends the message to it" do
|
||||
mocked_sender = double("SMTPSender")
|
||||
expect(mocked_sender).to receive(:send_message).with(queued_message.message).and_return(send_result)
|
||||
expect(state).to receive(:sender_for).with(Postal::SMTPSender, message.recipient_domain, ip).and_return(mocked_sender)
|
||||
expect(state).to receive(:sender_for).with(SMTPSender, message.recipient_domain, ip).and_return(mocked_sender)
|
||||
|
||||
processor.process
|
||||
end
|
||||
@@ -393,7 +393,7 @@ module MessageDequeuer
|
||||
it "gets a sender from the state and sends the message to it" do
|
||||
mocked_sender = double("SMTPSender")
|
||||
expect(mocked_sender).to receive(:send_message).with(queued_message.message).and_return(send_result)
|
||||
expect(state).to receive(:sender_for).with(Postal::SMTPSender, message.recipient_domain, nil).and_return(mocked_sender)
|
||||
expect(state).to receive(:sender_for).with(SMTPSender, message.recipient_domain, nil).and_return(mocked_sender)
|
||||
|
||||
processor.process
|
||||
end
|
||||
@@ -514,7 +514,7 @@ module MessageDequeuer
|
||||
context "when an exception occurrs during processing" do
|
||||
before do
|
||||
smtp_sender_mock = double("SMTPSender")
|
||||
allow(Postal::SMTPSender).to receive(:new).and_return(smtp_sender_mock)
|
||||
allow(SMTPSender).to receive(:new).and_return(smtp_sender_mock)
|
||||
allow(smtp_sender_mock).to receive(:start)
|
||||
allow(smtp_sender_mock).to receive(:send_message) do
|
||||
1 / 0
|
||||
|
||||
@@ -9,7 +9,7 @@ module MessageDequeuer
|
||||
|
||||
describe "#send_result" do
|
||||
it "can be get and set" do
|
||||
result = instance_double(Postal::SendResult)
|
||||
result = instance_double(SendResult)
|
||||
state.send_result = result
|
||||
expect(state.send_result).to be result
|
||||
end
|
||||
@@ -17,20 +17,20 @@ module MessageDequeuer
|
||||
|
||||
describe "#sender_for" do
|
||||
it "returns a instance of the given sender initialized with the args" do
|
||||
sender = state.sender_for(Postal::HTTPSender, "1234")
|
||||
expect(sender).to be_a Postal::HTTPSender
|
||||
sender = state.sender_for(HTTPSender, "1234")
|
||||
expect(sender).to be_a HTTPSender
|
||||
end
|
||||
|
||||
it "returns a cached sender on subsequent calls" do
|
||||
sender = state.sender_for(Postal::HTTPSender, "1234")
|
||||
expect(state.sender_for(Postal::HTTPSender, "1234")).to be sender
|
||||
sender = state.sender_for(HTTPSender, "1234")
|
||||
expect(state.sender_for(HTTPSender, "1234")).to be sender
|
||||
end
|
||||
end
|
||||
|
||||
describe "#finished" do
|
||||
it "calls finish on all cached senders" do
|
||||
sender1 = state.sender_for(Postal::HTTPSender, "1234")
|
||||
sender2 = state.sender_for(Postal::HTTPSender, "4444")
|
||||
sender1 = state.sender_for(HTTPSender, "1234")
|
||||
sender2 = state.sender_for(HTTPSender, "4444")
|
||||
expect(sender1).to receive(:finish)
|
||||
expect(sender2).to receive(:finish)
|
||||
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم