مراية لـ
https://github.com/postalserver/postal.git
تم المزامنة 2026-04-22 06:35:43 +00:00
436 أسطر
13 KiB
Ruby
436 أسطر
13 KiB
Ruby
require 'nifty/utils/random_string'
|
|
|
|
module Postal
|
|
module SMTPServer
|
|
class Client
|
|
|
|
CRAM_MD5_DIGEST = OpenSSL::Digest.new('md5')
|
|
|
|
attr_reader :logging_enabled
|
|
|
|
def initialize(ip_address)
|
|
@logging_enabled = true
|
|
@ip_address = ip_address
|
|
if @ip_address
|
|
check_ip_address
|
|
@state = :welcome
|
|
else
|
|
@state = :preauth
|
|
end
|
|
reset
|
|
end
|
|
|
|
def check_ip_address
|
|
if @ip_address && Postal.config.smtp_server.log_exclude_ips && @ip_address =~ Regexp.new(Postal.config.smtp_server.log_exclude_ips)
|
|
@logging_enabled = false
|
|
end
|
|
end
|
|
|
|
def transaction_reset
|
|
@recipients = []
|
|
@mail_from = nil
|
|
@data = nil
|
|
@headers = nil
|
|
end
|
|
|
|
def reset
|
|
@credential = nil
|
|
transaction_reset
|
|
end
|
|
|
|
def id
|
|
@id ||= Nifty::Utils::RandomString.generate(:length => 6).upcase
|
|
end
|
|
|
|
def handle(data)
|
|
if @state == :preauth
|
|
proxy(data)
|
|
else
|
|
if @proc
|
|
log "\e[32m<= #{data.strip}\e[0m"
|
|
@proc.call(data)
|
|
else
|
|
log "\e[32m<= #{data.strip}\e[0m"
|
|
handle_command(data)
|
|
end
|
|
end
|
|
end
|
|
|
|
def finished?
|
|
@finished || false
|
|
end
|
|
|
|
def start_tls?
|
|
@start_tls || false
|
|
end
|
|
|
|
def start_tls=(value)
|
|
@start_tls = value
|
|
end
|
|
|
|
def handle_command(data)
|
|
case data
|
|
when /^QUIT/i then quit
|
|
when /^STARTTLS/i then starttls
|
|
when /^EHLO/i then ehlo(data)
|
|
when /^HELO/i then helo(data)
|
|
when /^RSET/i then rset
|
|
when /^NOOP/i then noop
|
|
when /^AUTH PLAIN/i then auth_plain(data)
|
|
when /^AUTH LOGIN/i then auth_login(data)
|
|
when /^AUTH CRAM-MD5/i then auth_cram_md5(data)
|
|
when /^MAIL FROM/i then mail_from(data)
|
|
when /^RCPT TO/i then rcpt_to(data)
|
|
when /^DATA/i then data(data)
|
|
else
|
|
'502 Invalid/unsupported command'
|
|
end
|
|
end
|
|
|
|
def log(text)
|
|
return false unless @logging_enabled
|
|
Postal.logger_for(:smtp_server).debug "[#{id}] #{text}"
|
|
end
|
|
|
|
private
|
|
|
|
def resolve_hostname
|
|
@hostname = Resolv.new.getname(@ip_address) rescue @ip_address
|
|
end
|
|
|
|
def proxy(data)
|
|
if m = data.match(/\APROXY (.+) (.+) (.+) (.+) (.+)\z/)
|
|
@ip_address = m[2]
|
|
check_ip_address
|
|
@state = :welcome
|
|
log "\e[35m Client identified as #{@ip_address}\e[0m"
|
|
"220 #{Postal.config.dns.smtp_server_hostname} ESMTP Postal/#{id}"
|
|
else
|
|
@finished = true
|
|
'502 Proxy Error'
|
|
end
|
|
end
|
|
|
|
def quit
|
|
@finished = true
|
|
"221 Closing Connection"
|
|
end
|
|
|
|
def starttls
|
|
@start_tls = true
|
|
@tls = true
|
|
"220 Ready to start TLS"
|
|
end
|
|
|
|
def ehlo(data)
|
|
resolve_hostname
|
|
@helo_name = data.strip.split(' ', 2)[1]
|
|
reset
|
|
@state = :welcomed
|
|
["250-My capabilities are", @tls ? nil : "250-STARTTLS", "250 AUTH CRAM-MD5 PLAIN LOGIN", ]
|
|
end
|
|
|
|
def helo(data)
|
|
resolve_hostname
|
|
@helo_name = data.strip.split(' ', 2)[1]
|
|
reset
|
|
@state = :welcomed
|
|
"250 #{Postal.config.dns.smtp_server_hostname}"
|
|
end
|
|
|
|
def rset
|
|
reset
|
|
@state = :welcomed
|
|
'250 OK'
|
|
end
|
|
|
|
def noop
|
|
'250 OK'
|
|
end
|
|
|
|
def auth_plain(data)
|
|
handler = Proc.new do |data|
|
|
@proc = nil
|
|
data = Base64.decode64(data)
|
|
parts = data.split("\0")
|
|
username, password = parts[-2], parts[-1]
|
|
unless username && password
|
|
next '535 Authenticated failed - protocol error'
|
|
end
|
|
authenticate(password)
|
|
end
|
|
|
|
data = data.gsub(/AUTH PLAIN ?/i, '')
|
|
if data.strip == ''
|
|
@proc = handler
|
|
'334'
|
|
else
|
|
handler.call(data)
|
|
end
|
|
end
|
|
|
|
def auth_login(data)
|
|
password_handler = Proc.new do |data|
|
|
@proc = nil
|
|
password = Base64.decode64(data)
|
|
authenticate(password)
|
|
end
|
|
|
|
username_handler = Proc.new do |data|
|
|
@proc = password_handler
|
|
'334 UGFzc3dvcmQ6'
|
|
end
|
|
|
|
data = data.gsub!(/AUTH LOGIN ?/i, '')
|
|
if data.strip == ''
|
|
@proc = username_handler
|
|
'334 VXNlcm5hbWU6'
|
|
end
|
|
end
|
|
|
|
def authenticate(password)
|
|
if @credential = Credential.where(:type => 'SMTP', :key => password).first
|
|
@credential.use
|
|
"235 Granted for #{@credential.server.organization.permalink}/#{@credential.server.permalink}"
|
|
else
|
|
"535 Invalid credential"
|
|
end
|
|
end
|
|
|
|
def auth_cram_md5(data)
|
|
challenge = Digest::SHA1.hexdigest(Time.now.to_i.to_s + rand(100000).to_s)
|
|
challenge = "<#{challenge[0,20]}@#{Postal.config.dns.smtp_server_hostname}>"
|
|
|
|
handler = Proc.new do |data|
|
|
@proc = nil
|
|
username, password = Base64.decode64(data).split(' ', 2).map{ |a| a.chomp }
|
|
org_permlink, server_permalink = username.split(/[\/\_]/, 2)
|
|
server = ::Server.includes(:organization).where(:organizations => {:permalink => org_permlink}, :permalink => server_permalink).first
|
|
next '535 Denied' if server.nil?
|
|
grant = nil
|
|
server.credentials.where(:type => 'SMTP').each do |credential|
|
|
correct_response = OpenSSL::HMAC.hexdigest(CRAM_MD5_DIGEST, credential.key, challenge)
|
|
if password == correct_response
|
|
@credential = credential
|
|
@credential.use
|
|
grant = "235 Granted for #{credential.server.organization.permalink}/#{credential.server.permalink}"
|
|
break
|
|
end
|
|
end
|
|
grant || '535 Denied'
|
|
end
|
|
|
|
@proc = handler
|
|
"334 " + Base64.encode64(challenge).gsub(/[\r\n]/, '')
|
|
end
|
|
|
|
def mail_from(data)
|
|
unless in_state(:welcomed, :mail_from_received)
|
|
return '503 EHLO/HELO first please'
|
|
end
|
|
|
|
@state = :mail_from_received
|
|
transaction_reset
|
|
@mail_from = data.gsub(/MAIL FROM\s*:\s*/i, '').gsub(/.*</, '').gsub(/>.*/, '').strip
|
|
'250 OK'
|
|
end
|
|
|
|
def rcpt_to(data)
|
|
unless in_state(:mail_from_received, :rcpt_to_received)
|
|
return '503 EHLO/HELO and MAIL FROM first please'
|
|
end
|
|
|
|
rcpt_to = data.gsub(/RCPT TO\s*:\s*/i, '').gsub(/.*</, '').gsub(/>.*/, '').strip
|
|
uname, domain = rcpt_to.split('@', 2)
|
|
uname, tag = uname.split('+', 2)
|
|
|
|
if domain =~ /\A#{Regexp.escape(Postal.config.dns.custom_return_path_prefix)}\./
|
|
# This is a return path
|
|
@state = :rcpt_to_received
|
|
if server = ::Server.where(:token => uname).first
|
|
if server.suspended?
|
|
'535 Mail server has been suspended'
|
|
else
|
|
log "Added bounce on server #{server.id}"
|
|
@recipients << [:bounce, rcpt_to, server]
|
|
'250 OK'
|
|
end
|
|
else
|
|
'550 Invalid server token'
|
|
end
|
|
|
|
elsif domain == Postal.config.dns.route_domain
|
|
# This is an email direct to a route. This isn't actually supported yet.
|
|
@state = :rcpt_to_received
|
|
if route = Route.where(:token => uname).first
|
|
if route.server.suspended?
|
|
'535 Mail server has been suspended'
|
|
elsif route.mode == 'Reject'
|
|
'550 Route does not accept incoming messages'
|
|
else
|
|
log "Added route #{route.id} to recipients (tag: #{tag.inspect})"
|
|
actual_rcpt_to = "#{route.name}" + (tag ? "+#{tag}" : "") + "@#{route.domain.name}"
|
|
@recipients << [:route, actual_rcpt_to, route.server, :route => route]
|
|
'250 OK'
|
|
end
|
|
else
|
|
'550 Invalid route token'
|
|
end
|
|
|
|
elsif @credential
|
|
# This is outgoing mail for an authenticated user
|
|
@state = :rcpt_to_received
|
|
if @credential.server.suspended?
|
|
'535 Mail server has been suspended'
|
|
else
|
|
log "Added external address '#{rcpt_to}'"
|
|
@recipients << [:credential, rcpt_to, @credential.server]
|
|
'250 OK'
|
|
end
|
|
|
|
elsif uname && domain && route = Route.find_by_name_and_domain(uname, domain)
|
|
# This is incoming mail for a route
|
|
@state = :rcpt_to_received
|
|
if route.server.suspended?
|
|
'535 Mail server has been suspended'
|
|
elsif route.mode == 'Reject'
|
|
'550 Route does not accept incoming messages'
|
|
else
|
|
log "Added route #{route.id} to recipients (tag: #{tag.inspect})"
|
|
@recipients << [:route, rcpt_to, route.server, :route => route]
|
|
'250 OK'
|
|
end
|
|
|
|
else
|
|
# This is unaccepted mail
|
|
'530 Authentication required'
|
|
end
|
|
end
|
|
|
|
def data(data)
|
|
unless in_state(:rcpt_to_received)
|
|
return '503 HELO/EHLO, MAIL FROM and RCPT TO before sending data'
|
|
end
|
|
|
|
@data = "".force_encoding("BINARY")
|
|
@headers = {}
|
|
@receiving_headers = true
|
|
|
|
received_header_content = "from #{@helo_name} (#{@hostname} [#{@ip_address}]) by #{Postal.config.dns.smtp_server_hostname} with SMTP; #{Time.now.rfc2822.to_s}".force_encoding('BINARY')
|
|
@data << "Received: #{received_header_content}\r\n"
|
|
@headers['received'] = [received_header_content]
|
|
|
|
handler = Proc.new do |data|
|
|
if data == '.'
|
|
@logging_enabled = true
|
|
@proc = nil
|
|
finished
|
|
else
|
|
data = data.to_s.sub(/\A\.\./, '.')
|
|
|
|
if @credential && @credential.server.log_smtp_data?
|
|
# We want to log if enabled
|
|
else
|
|
log "Not logging further message data."
|
|
@logging_enabled = false
|
|
end
|
|
|
|
if @receiving_headers
|
|
if data.blank?
|
|
@receiving_headers = false
|
|
elsif data.to_s =~ /^\s/
|
|
# This is a continuation of a header
|
|
if @header_key && @headers[@header_key.downcase] && @headers[@header_key.downcase].last
|
|
@headers[@header_key.downcase].last << data.to_s
|
|
end
|
|
else
|
|
@header_key, value = data.split(/\:\s*/, 2)
|
|
@headers[@header_key.downcase] ||= []
|
|
@headers[@header_key.downcase] << value
|
|
end
|
|
end
|
|
@data << data
|
|
@data << "\r\n"
|
|
nil
|
|
end
|
|
end
|
|
|
|
@proc = handler
|
|
'354 Go ahead'
|
|
end
|
|
|
|
def finished
|
|
if @data.bytesize > 14.megabytes.to_i
|
|
return "552 Message too large (maximum size 14MB)"
|
|
end
|
|
|
|
if @headers['received'].select { |r| r =~ /by #{Postal.config.dns.smtp_server_hostname}/ }.count > 4
|
|
return '550 Loop detected'
|
|
end
|
|
|
|
authenticated_domain = nil
|
|
if @credential
|
|
authenticated_domain = @credential.server.find_authenticated_domain_from_headers(@headers)
|
|
if authenticated_domain.nil?
|
|
return '530 From/Sender name is not valid'
|
|
end
|
|
end
|
|
|
|
@recipients.each do |recipient|
|
|
type, rcpt_to, server, options = recipient
|
|
|
|
case type
|
|
when :credential
|
|
# Outgoing messages are just inserted
|
|
message = server.message_db.new_message
|
|
message.rcpt_to = rcpt_to
|
|
message.mail_from = @mail_from
|
|
message.raw_message = @data
|
|
message.received_with_ssl = @tls
|
|
message.scope = 'outgoing'
|
|
message.domain_id = authenticated_domain&.id
|
|
message.credential_id = @credential.id
|
|
message.save
|
|
|
|
when :bounce
|
|
if rp_route = server.routes.where(:name => "__returnpath__").first
|
|
# If there's a return path route, we can use this to create the message
|
|
rp_route.create_messages do |message|
|
|
message.rcpt_to = rcpt_to
|
|
message.mail_from = @mail_from
|
|
message.raw_message = @data
|
|
message.received_with_ssl = @tls
|
|
end
|
|
else
|
|
# There's no return path route, we just need to insert the mesage
|
|
# without going through the route.
|
|
message = server.message_db.new_message
|
|
message.rcpt_to = rcpt_to
|
|
message.mail_from = @mail_from
|
|
message.raw_message = @data
|
|
message.received_with_ssl = @tls
|
|
message.scope = 'incoming'
|
|
message.bounce = 1
|
|
message.save
|
|
end
|
|
when :route
|
|
options[:route].create_messages do |message|
|
|
message.rcpt_to = rcpt_to
|
|
message.mail_from = @mail_from
|
|
message.raw_message = @data
|
|
message.received_with_ssl = @tls
|
|
end
|
|
end
|
|
end
|
|
transaction_reset
|
|
'250 OK'
|
|
end
|
|
|
|
def in_state(*states)
|
|
states.include?(@state)
|
|
end
|
|
|
|
end
|
|
end
|
|
end
|