diff --git a/app/lib/smtp_server/client.rb b/app/lib/smtp_server/client.rb new file mode 100644 index 0000000..3040f7f --- /dev/null +++ b/app/lib/smtp_server/client.rb @@ -0,0 +1,507 @@ +# frozen_string_literal: true + +require "nifty/utils/random_string" + +module SMTPServer + class Client + + CRAM_MD5_DIGEST = OpenSSL::Digest.new("md5") + LOG_REDACTION_STRING = "[redacted]" + + attr_reader :logging_enabled + attr_reader :credential + attr_reader :ip_address + attr_reader :recipients + attr_reader :headers + attr_reader :state + attr_reader :helo_name + + def initialize(ip_address) + @logging_enabled = true + @ip_address = ip_address + if @ip_address + check_ip_address + @state = :welcome + else + @state = :preauth + end + transaction_reset + end + + def check_ip_address + return unless @ip_address && Postal.config.smtp_server.log_exclude_ips && @ip_address =~ Regexp.new(Postal.config.smtp_server.log_exclude_ips) + + @logging_enabled = false + end + + def transaction_reset + @recipients = [] + @mail_from = nil + @data = nil + @headers = nil + end + + def id + @id ||= Nifty::Utils::RandomString.generate(length: 6).upcase + end + + def handle(data) + Postal.logger.tagged(id: id) do + if @state == :preauth + return proxy(data) + end + + log "\e[32m<= #{sanitize_input_for_log(data.strip)}\e[0m" + if @proc + @proc.call(data) + + else + handle_command(data) + end + end + end + + def finished? + @finished || false + end + + def start_tls? + @start_tls || false + end + + attr_writer :start_tls + + 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.debug(text, id: id) + end + + private + + 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 + if Postal.config.smtp_server.tls_enabled? + @start_tls = true + @tls = true + "220 Ready to start TLS" + else + "502 TLS not available" + end + end + + def ehlo(data) + @helo_name = data.strip.split(" ", 2)[1] + transaction_reset + @state = :welcomed + [ + "250-My capabilities are", + Postal.config.smtp_server.tls_enabled? && !@tls ? "250-STARTTLS" : nil, + "250 AUTH CRAM-MD5 PLAIN LOGIN" + ].compact + end + + def helo(data) + @helo_name = data.strip.split(" ", 2)[1] + transaction_reset + @state = :welcomed + "250 #{Postal.config.dns.smtp_server_hostname}" + end + + def rset + transaction_reset + @state = :welcomed + "250 OK" + end + + def noop + "250 OK" + end + + def auth_plain(data) + handler = proc do |idata| + @proc = nil + idata = Base64.decode64(idata) + parts = idata.split("\0") + username = parts[-2] + password = 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 + @password_expected_next = true + "334" + else + handler.call(data) + end + end + + def auth_login(data) + password_handler = proc do |idata| + @proc = nil + password = Base64.decode64(idata) + authenticate(password) + end + + username_handler = proc do + @proc = password_handler + @password_expected_next = true + "334 UGFzc3dvcmQ6" # "Password:" + end + + data = data.gsub(/AUTH LOGIN ?/i, "") + if data.strip == "" + @proc = username_handler + "334 VXNlcm5hbWU6" # "Username:" + else + username_handler.call(nil) + 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 + log "\e[33m WARN: AUTH failure for #{@ip_address}\e[0m" + "535 Invalid credential" + end + end + + def auth_cram_md5(data) + challenge = Digest::SHA1.hexdigest(Time.now.to_i.to_s + rand(100_000).to_s) + challenge = "<#{challenge[0, 20]}@#{Postal.config.dns.smtp_server_hostname}>" + + handler = proc do |idata| + @proc = nil + username, password = Base64.decode64(idata).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 + if server.nil? + log "\e[33m WARN: AUTH failure for #{@ip_address}\e[0m" + next "535 Denied" + end + + grant = nil + server.credentials.where(type: "SMTP").each do |credential| + correct_response = OpenSSL::HMAC.hexdigest(CRAM_MD5_DIGEST, credential.key, challenge) + next unless password == correct_response + + @credential = credential + @credential.use + grant = "235 Granted for #{credential.server.organization.permalink}/#{credential.server.permalink}" + break + end + + if grant.nil? + log "\e[33m WARN: AUTH failure for #{@ip_address}\e[0m" + next "535 Denied" + end + + grant + 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 + if data =~ /AUTH=/ + # Discard AUTH= parameter and anything that follows. + # We don't need this parameter as we don't trust any client to set it + mail_from_line = data.sub(/ *AUTH=.*/, "") + else + mail_from_line = data + end + @mail_from = mail_from_line.gsub(/MAIL FROM\s*:\s*/i, "").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(/.*.*/, "").strip + + if rcpt_to.blank? + return "501 RCPT TO should not be empty" + end + + uname, domain = rcpt_to.split("@", 2) + + if domain.blank? + return "501 Invalid RCPT TO" + end + + uname, tag = uname.split("+", 2) + + if domain == Postal.config.dns.return_path || 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 + # User is trying to relay but is not authenticated. Try to authenticate by IP address + @credential = Credential.where(type: "SMTP-IP").all.sort_by { |c| c.ipaddr&.prefix || 0 }.reverse.find do |credential| + credential.ipaddr.include?(@ip_address) || (credential.ipaddr.ipv4? && credential.ipaddr.ipv4_mapped.include?(@ip_address)) + end + + if @credential + # Retry with credential + @credential.use + rcpt_to(data) + else + "530 Authentication required" + end + 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 = String.new.force_encoding("BINARY") + @headers = {} + @receiving_headers = true + + received_header = Postal::ReceivedHeader.generate(@credential&.server, @helo_name, @ip_address, :smtp) + .force_encoding("BINARY") + + @data << "Received: #{received_header}\r\n" + @headers["received"] = [received_header] + + handler = proc do |idata| + if idata == "." + @logging_enabled = true + @proc = nil + finished + else + idata = idata.to_s.sub(/\A\.\./, ".") + + if @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 idata.blank? + @receiving_headers = false + elsif idata.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 << idata.to_s + end + else + @header_key, value = idata.split(/:\s*/, 2) + @headers[@header_key.downcase] ||= [] + @headers[@header_key.downcase] << value + end + end + @data << idata + @data << "\r\n" + nil + end + end + + @proc = handler + "354 Go ahead" + end + + def finished + if @data.bytesize > Postal.config.smtp_server.max_message_size.megabytes.to_i + transaction_reset + @state = :welcomed + return format("552 Message too large (maximum size %dMB)", Postal.config.smtp_server.max_message_size) + end + + if @headers["received"].grep(/by #{Postal.config.dns.smtp_server_hostname}/).count > 4 + transaction_reset + @state = :welcomed + return "550 Loop detected" + end + + authenticated_domain = nil + if @credential + authenticated_domain = @credential.server.find_authenticated_domain_from_headers(@headers) + if authenticated_domain.nil? + transaction_reset + @state = :welcomed + 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 |msg| + msg.rcpt_to = rcpt_to + msg.mail_from = @mail_from + msg.raw_message = @data + msg.received_with_ssl = @tls + msg.bounce = 1 + 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 + @state = :welcomed + "250 OK" + end + + def in_state(*states) + states.include?(@state) + end + + def sanitize_input_for_log(data) + if @password_expected_next + @password_expected_next = false + if data =~ /\A[a-z0-9]{3,}=*\z/i + return LOG_REDACTION_STRING + end + end + + data = data.dup + data.gsub!(/(.*AUTH \w+) (.*)\z/i) { "#{::Regexp.last_match(1)} #{LOG_REDACTION_STRING}" } + data + end + + end +end diff --git a/app/lib/smtp_server/server.rb b/app/lib/smtp_server/server.rb new file mode 100644 index 0000000..e51fadd --- /dev/null +++ b/app/lib/smtp_server/server.rb @@ -0,0 +1,272 @@ +# frozen_string_literal: true + +require "ipaddr" +require "nio" + +module SMTPServer + class Server + + def initialize(options = {}) + @options = options + @options[:debug] ||= false + prepare_environment + end + + def run + logger.tagged(component: "smtp-server") do + listen + run_event_loop + end + end + + private + + def prepare_environment + $\ = "\r\n" + BasicSocket.do_not_reverse_lookup = true + + trap("TERM") do + $stdout.puts "Received TERM signal, shutting down." + unlisten + end + + trap("INT") do + $stdout.puts "Received INT signal, shutting down." + unlisten + end + end + + def ssl_context + @ssl_context ||= begin + ssl_context = OpenSSL::SSL::SSLContext.new + ssl_context.cert = Postal.smtp_certificates[0] + ssl_context.extra_chain_cert = Postal.smtp_certificates[1..] + ssl_context.key = Postal.smtp_private_key + ssl_context.ssl_version = Postal.config.smtp_server.ssl_version if Postal.config.smtp_server.ssl_version + ssl_context.ciphers = Postal.config.smtp_server.tls_ciphers if Postal.config.smtp_server.tls_ciphers + ssl_context + end + end + + def listen + @server = TCPServer.open(Postal.config.smtp_server.bind_address, Postal.config.smtp_server.port) + @server.autoclose = false + @server.close_on_exec = false + if defined?(Socket::SOL_SOCKET) && defined?(Socket::SO_KEEPALIVE) + @server.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true) + end + if defined?(Socket::SOL_TCP) && defined?(Socket::TCP_KEEPIDLE) && defined?(Socket::TCP_KEEPINTVL) && defined?(Socket::TCP_KEEPCNT) + @server.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPIDLE, 50) + @server.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPINTVL, 10) + @server.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPCNT, 5) + end + logger.info "Listening on #{Postal.config.smtp_server.bind_address}:#{Postal.config.smtp_server.port}" + end + + def unlisten + # Instruct the nio loop to unlisten and wake it + @unlisten = true + @io_selector.wakeup + end + + def run_event_loop + # Set up an instance of nio4r to monitor for connections and data + @io_selector = NIO::Selector.new + # Register the SMTP listener + @io_selector.register(@server, :r) + # Create a hash to contain a buffer for each client. + buffers = Hash.new { |h, k| h[k] = String.new.force_encoding("BINARY") } + loop do + # Wait for an event to occur + @io_selector.select do |monitor| + # Get the IO from the nio monitor + io = monitor.io + # Is this event an incoming connection? + if io.is_a?(TCPServer) + begin + # Accept the connection + new_io = io.accept + if Postal.config.smtp_server.proxy_protocol + # If we are using the haproxy proxy protocol, we will be sent the + # client's IP later. Delay the welcome process. + client = Client.new(nil) + if Postal.config.smtp_server.log_connect + logger.debug "[#{client.id}] \e[35m Connection opened from #{new_io.remote_address.ip_address}\e[0m" + end + else + # We're not using the proxy protocol so we already know the client's IP + client = Client.new(new_io.remote_address.ip_address) + if Postal.config.smtp_server.log_connect + logger.debug "[#{client.id}] \e[35m Connection opened from #{new_io.remote_address.ip_address}\e[0m" + end + # We know who the client is, welcome them. + client.log "\e[35m Client identified as #{new_io.remote_address.ip_address}\e[0m" + new_io.print("220 #{Postal.config.dns.smtp_server_hostname} ESMTP Postal/#{client.id}") + end + # Register the client and its socket with nio4r + monitor = @io_selector.register(new_io, :r) + monitor.value = client + rescue StandardError => e + # If something goes wrong, log as appropriate and disconnect the client + if defined?(Sentry) + Sentry.capture_exception(e, extra: { log_id: begin + client.id + rescue StandardError + nil + end }) + end + logger.error "An error occurred while accepting a new client." + logger.error "#{e.class}: #{e.message}" + e.backtrace.each do |line| + logger.error line + end + begin + new_io.close + rescue StandardError + nil + end + end + else + # This event is not an incoming connection so it must be data from a client + begin + # Get the client from the nio monitor + client = monitor.value + # For now we assume the connection isn't closed + eof = false + # Is the client negotiating a TLS handshake? + if client.start_tls? + begin + # Can we accept the TLS connection at this time? + io.accept_nonblock + # We were able to accept the connection, the client is no longer handshaking + client.start_tls = false + rescue IO::WaitReadable, IO::WaitWritable => e + # Could not accept without blocking + # We will try again later + next + rescue OpenSSL::SSL::SSLError => e + client.log "SSL Negotiation Failed: #{e.message}" + eof = true + end + else + # The client is not negotiating a TLS handshake at this time + begin + # Read 10kiB of data at a time from the socket. + buffers[io] << io.readpartial(10_240) + + # There is an extra step for SSL sockets + if io.is_a?(OpenSSL::SSL::SSLSocket) + buffers[io] << io.readpartial(10_240) while io.pending.positive? + end + rescue EOFError, Errno::ECONNRESET, Errno::ETIMEDOUT + # Client went away + eof = true + end + + # Normalize all \r\n and \n to \r\n, but ignore only \r. + # A \r\n may be split in 2 buffers (\n in one buffer and \r in the other) + buffers[io] = buffers[io].gsub(/\r/, "").encode(buffers[io].encoding, crlf_newline: true) + + # We line buffer, so look to see if we have received a newline + # and keep doing so until all buffered lines have been processed. + while buffers[io].index("\r\n") + # Extract the line + line, buffers[io] = buffers[io].split("\r\n", 2) + + # Send the received line to the client object for processing + result = client.handle(line) + # If the client object returned some data, write it back to the client + next if result.nil? + + result = [result] unless result.is_a?(Array) + result.compact.each do |iline| + client.log "\e[34m=> #{iline.strip}\e[0m" + begin + io.write(iline.to_s + "\r\n") + io.flush + rescue Errno::ECONNRESET + # Client disconnected before we could write response + eof = true + end + end + end + + # Did the client request STARTTLS? + if !eof && client.start_tls? + # Deregister the unencrypted IO + @io_selector.deregister(io) + buffers.delete(io) + io = OpenSSL::SSL::SSLSocket.new(io, ssl_context) + # Close the underlying IO when the TLS socket is closed + io.sync_close = true + # Register the new TLS socket with nio + monitor = @io_selector.register(io, :r) + monitor.value = client + end + end + + # Has the client requested we close the connection? + if client.finished? || eof + client.log "\e[35m Connection closed\e[0m" + # Deregister the socket and close it + @io_selector.deregister(io) + buffers.delete(io) + io.close + # If we have no more clients or listeners left, exit the process + if @io_selector.empty? + Process.exit(0) + end + end + rescue StandardError => e + # Something went wrong, log as appropriate + client_id = client ? client.id : "------" + if defined?(Sentry) + Sentry.capture_exception(e, extra: { log_id: begin + client.id + rescue StandardError + nil + end }) + end + logger.error "[#{client_id}] An error occurred while processing data from a client." + logger.error "[#{client_id}] #{e.class}: #{e.message}" + e.backtrace.each do |iline| + logger.error "[#{client_id}] #{iline}" + end + # Close all IO and forget this client + begin + @io_selector.deregister(io) + rescue StandardError + nil + end + buffers.delete(io) + begin + io.close + rescue StandardError + nil + end + if @io_selector.empty? + Process.exit(0) + end + end + end + end + # If unlisten has been called, stop listening + next unless @unlisten + + @io_selector.deregister(@server) + @server.close + # If there's nothing left to do, shut down the process + if @io_selector.empty? + Process.exit(0) + end + # Clear the request + @unlisten = false + end + end + + def logger + Postal.logger + end + + end +end diff --git a/lib/postal/smtp_server.rb b/lib/postal/smtp_server.rb deleted file mode 100644 index 154ae62..0000000 --- a/lib/postal/smtp_server.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -module Postal - module SMTPServer - end -end diff --git a/lib/postal/smtp_server/client.rb b/lib/postal/smtp_server/client.rb deleted file mode 100644 index 6aedcfc..0000000 --- a/lib/postal/smtp_server/client.rb +++ /dev/null @@ -1,510 +0,0 @@ -# frozen_string_literal: true - -require "resolv" -require "nifty/utils/random_string" - -module Postal - module SMTPServer - class Client - - CRAM_MD5_DIGEST = OpenSSL::Digest.new("md5") - LOG_REDACTION_STRING = "[redacted]" - - attr_reader :logging_enabled - attr_reader :credential - attr_reader :ip_address - attr_reader :recipients - attr_reader :headers - attr_reader :state - attr_reader :helo_name - - def initialize(ip_address) - @logging_enabled = true - @ip_address = ip_address - if @ip_address - check_ip_address - @state = :welcome - else - @state = :preauth - end - transaction_reset - end - - def check_ip_address - return unless @ip_address && Postal.config.smtp_server.log_exclude_ips && @ip_address =~ Regexp.new(Postal.config.smtp_server.log_exclude_ips) - - @logging_enabled = false - end - - def transaction_reset - @recipients = [] - @mail_from = nil - @data = nil - @headers = nil - end - - def id - @id ||= Nifty::Utils::RandomString.generate(length: 6).upcase - end - - def handle(data) - Postal.logger.tagged(id: id) do - if @state == :preauth - return proxy(data) - end - - log "\e[32m<= #{sanitize_input_for_log(data.strip)}\e[0m" - if @proc - @proc.call(data) - - else - handle_command(data) - end - end - end - - def finished? - @finished || false - end - - def start_tls? - @start_tls || false - end - - attr_writer :start_tls - - 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.debug(text, id: id) - end - - private - - 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 - if Postal.config.smtp_server.tls_enabled? - @start_tls = true - @tls = true - "220 Ready to start TLS" - else - "502 TLS not available" - end - end - - def ehlo(data) - @helo_name = data.strip.split(" ", 2)[1] - transaction_reset - @state = :welcomed - [ - "250-My capabilities are", - Postal.config.smtp_server.tls_enabled? && !@tls ? "250-STARTTLS" : nil, - "250 AUTH CRAM-MD5 PLAIN LOGIN" - ].compact - end - - def helo(data) - @helo_name = data.strip.split(" ", 2)[1] - transaction_reset - @state = :welcomed - "250 #{Postal.config.dns.smtp_server_hostname}" - end - - def rset - transaction_reset - @state = :welcomed - "250 OK" - end - - def noop - "250 OK" - end - - def auth_plain(data) - handler = proc do |idata| - @proc = nil - idata = Base64.decode64(idata) - parts = idata.split("\0") - username = parts[-2] - password = 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 - @password_expected_next = true - "334" - else - handler.call(data) - end - end - - def auth_login(data) - password_handler = proc do |idata| - @proc = nil - password = Base64.decode64(idata) - authenticate(password) - end - - username_handler = proc do - @proc = password_handler - @password_expected_next = true - "334 UGFzc3dvcmQ6" # "Password:" - end - - data = data.gsub(/AUTH LOGIN ?/i, "") - if data.strip == "" - @proc = username_handler - "334 VXNlcm5hbWU6" # "Username:" - else - username_handler.call(nil) - 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 - log "\e[33m WARN: AUTH failure for #{@ip_address}\e[0m" - "535 Invalid credential" - end - end - - def auth_cram_md5(data) - challenge = Digest::SHA1.hexdigest(Time.now.to_i.to_s + rand(100_000).to_s) - challenge = "<#{challenge[0, 20]}@#{Postal.config.dns.smtp_server_hostname}>" - - handler = proc do |idata| - @proc = nil - username, password = Base64.decode64(idata).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 - if server.nil? - log "\e[33m WARN: AUTH failure for #{@ip_address}\e[0m" - next "535 Denied" - end - - grant = nil - server.credentials.where(type: "SMTP").each do |credential| - correct_response = OpenSSL::HMAC.hexdigest(CRAM_MD5_DIGEST, credential.key, challenge) - next unless password == correct_response - - @credential = credential - @credential.use - grant = "235 Granted for #{credential.server.organization.permalink}/#{credential.server.permalink}" - break - end - - if grant.nil? - log "\e[33m WARN: AUTH failure for #{@ip_address}\e[0m" - next "535 Denied" - end - - grant - 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 - if data =~ /AUTH=/ - # Discard AUTH= parameter and anything that follows. - # We don't need this parameter as we don't trust any client to set it - mail_from_line = data.sub(/ *AUTH=.*/, "") - else - mail_from_line = data - end - @mail_from = mail_from_line.gsub(/MAIL FROM\s*:\s*/i, "").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(/.*.*/, "").strip - - if rcpt_to.blank? - return "501 RCPT TO should not be empty" - end - - uname, domain = rcpt_to.split("@", 2) - - if domain.blank? - return "501 Invalid RCPT TO" - end - - uname, tag = uname.split("+", 2) - - if domain == Postal.config.dns.return_path || 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 - # User is trying to relay but is not authenticated. Try to authenticate by IP address - @credential = Credential.where(type: "SMTP-IP").all.sort_by { |c| c.ipaddr&.prefix || 0 }.reverse.find do |credential| - credential.ipaddr.include?(@ip_address) || (credential.ipaddr.ipv4? && credential.ipaddr.ipv4_mapped.include?(@ip_address)) - end - - if @credential - # Retry with credential - @credential.use - rcpt_to(data) - else - "530 Authentication required" - end - 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 = String.new.force_encoding("BINARY") - @headers = {} - @receiving_headers = true - - received_header = Postal::ReceivedHeader.generate(@credential&.server, @helo_name, @ip_address, :smtp) - .force_encoding("BINARY") - - @data << "Received: #{received_header}\r\n" - @headers["received"] = [received_header] - - handler = proc do |idata| - if idata == "." - @logging_enabled = true - @proc = nil - finished - else - idata = idata.to_s.sub(/\A\.\./, ".") - - if @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 idata.blank? - @receiving_headers = false - elsif idata.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 << idata.to_s - end - else - @header_key, value = idata.split(/:\s*/, 2) - @headers[@header_key.downcase] ||= [] - @headers[@header_key.downcase] << value - end - end - @data << idata - @data << "\r\n" - nil - end - end - - @proc = handler - "354 Go ahead" - end - - def finished - if @data.bytesize > Postal.config.smtp_server.max_message_size.megabytes.to_i - transaction_reset - @state = :welcomed - return format("552 Message too large (maximum size %dMB)", Postal.config.smtp_server.max_message_size) - end - - if @headers["received"].grep(/by #{Postal.config.dns.smtp_server_hostname}/).count > 4 - transaction_reset - @state = :welcomed - return "550 Loop detected" - end - - authenticated_domain = nil - if @credential - authenticated_domain = @credential.server.find_authenticated_domain_from_headers(@headers) - if authenticated_domain.nil? - transaction_reset - @state = :welcomed - 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 |msg| - msg.rcpt_to = rcpt_to - msg.mail_from = @mail_from - msg.raw_message = @data - msg.received_with_ssl = @tls - msg.bounce = 1 - 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 - @state = :welcomed - "250 OK" - end - - def in_state(*states) - states.include?(@state) - end - - def sanitize_input_for_log(data) - if @password_expected_next - @password_expected_next = false - if data =~ /\A[a-z0-9]{3,}=*\z/i - return LOG_REDACTION_STRING - end - end - - data = data.dup - data.gsub!(/(.*AUTH \w+) (.*)\z/i) { "#{::Regexp.last_match(1)} #{LOG_REDACTION_STRING}" } - data - end - - end - end -end diff --git a/lib/postal/smtp_server/server.rb b/lib/postal/smtp_server/server.rb deleted file mode 100644 index 3a110a0..0000000 --- a/lib/postal/smtp_server/server.rb +++ /dev/null @@ -1,274 +0,0 @@ -# frozen_string_literal: true - -require "ipaddr" -require "nio" - -module Postal - module SMTPServer - class Server - - def initialize(options = {}) - @options = options - @options[:debug] ||= false - prepare_environment - end - - def run - logger.tagged(component: "smtp-server") do - listen - run_event_loop - end - end - - private - - def prepare_environment - $\ = "\r\n" - BasicSocket.do_not_reverse_lookup = true - - trap("TERM") do - $stdout.puts "Received TERM signal, shutting down." - unlisten - end - - trap("INT") do - $stdout.puts "Received INT signal, shutting down." - unlisten - end - end - - def ssl_context - @ssl_context ||= begin - ssl_context = OpenSSL::SSL::SSLContext.new - ssl_context.cert = Postal.smtp_certificates[0] - ssl_context.extra_chain_cert = Postal.smtp_certificates[1..] - ssl_context.key = Postal.smtp_private_key - ssl_context.ssl_version = Postal.config.smtp_server.ssl_version if Postal.config.smtp_server.ssl_version - ssl_context.ciphers = Postal.config.smtp_server.tls_ciphers if Postal.config.smtp_server.tls_ciphers - ssl_context - end - end - - def listen - @server = TCPServer.open(Postal.config.smtp_server.bind_address, Postal.config.smtp_server.port) - @server.autoclose = false - @server.close_on_exec = false - if defined?(Socket::SOL_SOCKET) && defined?(Socket::SO_KEEPALIVE) - @server.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true) - end - if defined?(Socket::SOL_TCP) && defined?(Socket::TCP_KEEPIDLE) && defined?(Socket::TCP_KEEPINTVL) && defined?(Socket::TCP_KEEPCNT) - @server.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPIDLE, 50) - @server.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPINTVL, 10) - @server.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPCNT, 5) - end - logger.info "Listening on #{Postal.config.smtp_server.bind_address}:#{Postal.config.smtp_server.port}" - end - - def unlisten - # Instruct the nio loop to unlisten and wake it - @unlisten = true - @io_selector.wakeup - end - - def run_event_loop - # Set up an instance of nio4r to monitor for connections and data - @io_selector = NIO::Selector.new - # Register the SMTP listener - @io_selector.register(@server, :r) - # Create a hash to contain a buffer for each client. - buffers = Hash.new { |h, k| h[k] = String.new.force_encoding("BINARY") } - loop do - # Wait for an event to occur - @io_selector.select do |monitor| - # Get the IO from the nio monitor - io = monitor.io - # Is this event an incoming connection? - if io.is_a?(TCPServer) - begin - # Accept the connection - new_io = io.accept - if Postal.config.smtp_server.proxy_protocol - # If we are using the haproxy proxy protocol, we will be sent the - # client's IP later. Delay the welcome process. - client = Client.new(nil) - if Postal.config.smtp_server.log_connect - logger.debug "[#{client.id}] \e[35m Connection opened from #{new_io.remote_address.ip_address}\e[0m" - end - else - # We're not using the proxy protocol so we already know the client's IP - client = Client.new(new_io.remote_address.ip_address) - if Postal.config.smtp_server.log_connect - logger.debug "[#{client.id}] \e[35m Connection opened from #{new_io.remote_address.ip_address}\e[0m" - end - # We know who the client is, welcome them. - client.log "\e[35m Client identified as #{new_io.remote_address.ip_address}\e[0m" - new_io.print("220 #{Postal.config.dns.smtp_server_hostname} ESMTP Postal/#{client.id}") - end - # Register the client and its socket with nio4r - monitor = @io_selector.register(new_io, :r) - monitor.value = client - rescue StandardError => e - # If something goes wrong, log as appropriate and disconnect the client - if defined?(Sentry) - Sentry.capture_exception(e, extra: { log_id: begin - client.id - rescue StandardError - nil - end }) - end - logger.error "An error occurred while accepting a new client." - logger.error "#{e.class}: #{e.message}" - e.backtrace.each do |line| - logger.error line - end - begin - new_io.close - rescue StandardError - nil - end - end - else - # This event is not an incoming connection so it must be data from a client - begin - # Get the client from the nio monitor - client = monitor.value - # For now we assume the connection isn't closed - eof = false - # Is the client negotiating a TLS handshake? - if client.start_tls? - begin - # Can we accept the TLS connection at this time? - io.accept_nonblock - # We were able to accept the connection, the client is no longer handshaking - client.start_tls = false - rescue IO::WaitReadable, IO::WaitWritable => e - # Could not accept without blocking - # We will try again later - next - rescue OpenSSL::SSL::SSLError => e - client.log "SSL Negotiation Failed: #{e.message}" - eof = true - end - else - # The client is not negotiating a TLS handshake at this time - begin - # Read 10kiB of data at a time from the socket. - buffers[io] << io.readpartial(10_240) - - # There is an extra step for SSL sockets - if io.is_a?(OpenSSL::SSL::SSLSocket) - buffers[io] << io.readpartial(10_240) while io.pending.positive? - end - rescue EOFError, Errno::ECONNRESET, Errno::ETIMEDOUT - # Client went away - eof = true - end - - # Normalize all \r\n and \n to \r\n, but ignore only \r. - # A \r\n may be split in 2 buffers (\n in one buffer and \r in the other) - buffers[io] = buffers[io].gsub(/\r/, "").encode(buffers[io].encoding, crlf_newline: true) - - # We line buffer, so look to see if we have received a newline - # and keep doing so until all buffered lines have been processed. - while buffers[io].index("\r\n") - # Extract the line - line, buffers[io] = buffers[io].split("\r\n", 2) - - # Send the received line to the client object for processing - result = client.handle(line) - # If the client object returned some data, write it back to the client - next if result.nil? - - result = [result] unless result.is_a?(Array) - result.compact.each do |iline| - client.log "\e[34m=> #{iline.strip}\e[0m" - begin - io.write(iline.to_s + "\r\n") - io.flush - rescue Errno::ECONNRESET - # Client disconnected before we could write response - eof = true - end - end - end - - # Did the client request STARTTLS? - if !eof && client.start_tls? - # Deregister the unencrypted IO - @io_selector.deregister(io) - buffers.delete(io) - io = OpenSSL::SSL::SSLSocket.new(io, ssl_context) - # Close the underlying IO when the TLS socket is closed - io.sync_close = true - # Register the new TLS socket with nio - monitor = @io_selector.register(io, :r) - monitor.value = client - end - end - - # Has the client requested we close the connection? - if client.finished? || eof - client.log "\e[35m Connection closed\e[0m" - # Deregister the socket and close it - @io_selector.deregister(io) - buffers.delete(io) - io.close - # If we have no more clients or listeners left, exit the process - if @io_selector.empty? - Process.exit(0) - end - end - rescue StandardError => e - # Something went wrong, log as appropriate - client_id = client ? client.id : "------" - if defined?(Sentry) - Sentry.capture_exception(e, extra: { log_id: begin - client.id - rescue StandardError - nil - end }) - end - logger.error "[#{client_id}] An error occurred while processing data from a client." - logger.error "[#{client_id}] #{e.class}: #{e.message}" - e.backtrace.each do |iline| - logger.error "[#{client_id}] #{iline}" - end - # Close all IO and forget this client - begin - @io_selector.deregister(io) - rescue StandardError - nil - end - buffers.delete(io) - begin - io.close - rescue StandardError - nil - end - if @io_selector.empty? - Process.exit(0) - end - end - end - end - # If unlisten has been called, stop listening - next unless @unlisten - - @io_selector.deregister(@server) - @server.close - # If there's nothing left to do, shut down the process - if @io_selector.empty? - Process.exit(0) - end - # Clear the request - @unlisten = false - end - end - - def logger - Postal.logger - end - - end - end -end diff --git a/script/smtp_server.rb b/script/smtp_server.rb index f9ea52f..69211ef 100644 --- a/script/smtp_server.rb +++ b/script/smtp_server.rb @@ -1,4 +1,4 @@ # frozen_string_literal: true require_relative "../config/environment" -Postal::SMTPServer::Server.new(debug: true).run +SMTPServer::Server.new(debug: true).run diff --git a/spec/lib/postal/smtp_server/client/auth_spec.rb b/spec/lib/postal/smtp_server/client/auth_spec.rb deleted file mode 100644 index bdb271d..0000000 --- a/spec/lib/postal/smtp_server/client/auth_spec.rb +++ /dev/null @@ -1,122 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -module Postal - module SMTPServer - - describe Client do - let(:ip_address) { "1.2.3.4" } - subject(:client) { described_class.new(ip_address) } - - before do - client.handle("HELO test.example.com") - end - - describe "AUTH PLAIN" do - context "when no credentials are provided on the initial data" do - it "returns a 334" do - expect(client.handle("AUTH PLAIN")).to eq("334") - end - - it "accepts the username and password from the next input" do - client.handle("AUTH PLAIN") - credential = create(:credential, type: "SMTP") - expect(client.handle(credential.to_smtp_plain)).to match(/235 Granted for/) - end - end - - context "when valid credentials are provided on one line" do - it "authenticates and returns a response" do - credential = create(:credential, type: "SMTP") - expect(client.handle("AUTH PLAIN #{credential.to_smtp_plain}")).to match(/235 Granted for/) - expect(client.credential).to eq credential - end - end - - context "when invalid credentials are provided" do - it "returns an error and resets the state" do - base64 = Base64.encode64("user\0pass") - expect(client.handle("AUTH PLAIN #{base64}")).to eq("535 Invalid credential") - expect(client.state).to eq :welcomed - end - end - - context "when username or password is missing" do - it "returns an error and resets the state" do - base64 = Base64.encode64("pass") - expect(client.handle("AUTH PLAIN #{base64}")).to eq("535 Authenticated failed - protocol error") - expect(client.state).to eq :welcomed - end - end - end - - describe "AUTH LOGIN" do - context "when no username is provided on the first line" do - it "requests the username" do - expect(client.handle("AUTH LOGIN")).to eq("334 VXNlcm5hbWU6") - end - end - - context "when a username is provided on the first line" do - it "requests a password" do - username = Base64.encode64("xx") - expect(client.handle("AUTH LOGIN #{username}")).to eq("334 UGFzc3dvcmQ6") - end - - it "authenticates and returns a response" do - credential = create(:credential, type: "SMTP") - username = Base64.encode64("xx") - password = Base64.encode64(credential.key) - expect(client.handle("AUTH LOGIN #{username}")).to eq("334 UGFzc3dvcmQ6") - expect(client.handle(password)).to match(/235 Granted for/) - expect(client.credential).to eq credential - end - end - - context "when invalid credentials are provided" do - it "returns an error and resets the state" do - username = Base64.encode64("xx") - password = Base64.encode64("xx") - expect(client.handle("AUTH LOGIN #{username}")).to eq("334 UGFzc3dvcmQ6") - expect(client.handle(password)).to eq("535 Invalid credential") - expect(client.state).to eq :welcomed - end - end - end - - describe "AUTH CRAM-MD5" do - context "when valid credentials are provided" do - it "authenticates and returns a response" do - credential = create(:credential, type: "SMTP") - result = client.handle("AUTH CRAM-MD5") - expect(result).to match(/\A334 [A-Za-z0-9=]+\z/) - challenge = Base64.decode64(result.split[1]) - password = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("md5"), credential.key, challenge) - base64 = Base64.encode64("#{credential.server.organization.permalink}/#{credential.server.permalink} #{password}") - expect(client.handle(base64)).to match(/235 Granted for/) - expect(client.credential).to eq credential - end - end - - context "when no org/server matches the provided username" do - it "returns an error" do - client.handle("AUTH CRAM-MD5") - base64 = Base64.encode64("org/server password") - expect(client.handle(base64)).to eq "535 Denied" - end - end - - context "when invalid credentials are provided" do - it "returns an error and resets the state" do - server = create(:server) - base64 = Base64.encode64("#{server.organization.permalink}/#{server.permalink} invalid-password") - client.handle("AUTH CRAM-MD5") - expect(client.handle(base64)).to eq("535 Denied") - end - end - end - end - - end -end diff --git a/spec/lib/postal/smtp_server/client/data_spec.rb b/spec/lib/postal/smtp_server/client/data_spec.rb deleted file mode 100644 index b4a9d8f..0000000 --- a/spec/lib/postal/smtp_server/client/data_spec.rb +++ /dev/null @@ -1,89 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -module Postal - module SMTPServer - - describe Client do - let(:ip_address) { "1.2.3.4" } - subject(:client) { described_class.new(ip_address) } - - describe "DATA" do - it "returns an error if no helo" do - expect(client.handle("DATA")).to eq "503 HELO/EHLO, MAIL FROM and RCPT TO before sending data" - end - - it "returns an error if no mail from" do - client.handle("HELO test.example.com") - expect(client.handle("DATA")).to eq "503 HELO/EHLO, MAIL FROM and RCPT TO before sending data" - end - - it "returns an error if no rcpt to" do - client.handle("HELO test.example.com") - client.handle("MAIL FROM: test@example.com") - expect(client.handle("DATA")).to eq "503 HELO/EHLO, MAIL FROM and RCPT TO before sending data" - end - - it "returns go ahead" do - route = create(:route) - client.handle("HELO test.example.com") - client.handle("MAIL FROM: test@test.com") - client.handle("RCPT TO: #{route.name}@#{route.domain.name}") - expect(client.handle("DATA")).to eq "354 Go ahead" - end - - it "adds a received header for itself" do - route = create(:route) - client.handle("HELO test.example.com") - client.handle("MAIL FROM: test@test.com") - client.handle("RCPT TO: #{route.name}@#{route.domain.name}") - Timecop.freeze do - client.handle("DATA") - expect(client.headers["received"]).to include "from test.example.com (1.2.3.4 [1.2.3.4]) by postal.example.com with SMTP; #{Time.now.utc.rfc2822}" - end - end - - describe "subsequent commands" do - let(:route) { create(:route) } - before do - client.handle("HELO test.example.com") - client.handle("MAIL FROM: test@test.com") - client.handle("RCPT TO: #{route.name}@#{route.domain.name}") - end - - it "logs headers" do - client.handle("DATA") - client.handle("Subject: Test") - client.handle("From: test@test.com") - client.handle("To: test1@example.com") - client.handle("To: test2@example.com") - client.handle("X-Something: abcdef1234") - expect(client.headers["subject"]).to eq ["Test"] - expect(client.headers["from"]).to eq ["test@test.com"] - expect(client.headers["to"]).to eq ["test1@example.com", "test2@example.com"] - expect(client.headers["x-something"]).to eq ["abcdef1234"] - end - - it "logs content" do - Timecop.freeze do - client.handle("DATA") - client.handle("Subject: Test") - client.handle("") - client.handle("This is some content for the message.") - client.handle("It will keep going.") - expect(client.instance_variable_get("@data")).to eq <<~DATA - Received: from test.example.com (1.2.3.4 [1.2.3.4]) by #{Postal.config.dns.smtp_server_hostname} with SMTP; #{Time.now.utc.rfc2822}\r - Subject: Test\r - \r - This is some content for the message.\r - It will keep going.\r - DATA - end - end - end - end - end - - end -end diff --git a/spec/lib/postal/smtp_server/client/finished_spec.rb b/spec/lib/postal/smtp_server/client/finished_spec.rb deleted file mode 100644 index aa6b48c..0000000 --- a/spec/lib/postal/smtp_server/client/finished_spec.rb +++ /dev/null @@ -1,208 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -module Postal - module SMTPServer - - describe Client do - let(:ip_address) { "1.2.3.4" } - let(:server) { create(:server) } - subject(:client) { described_class.new(ip_address) } - - let(:credential) { create(:credential, server: server, type: "SMTP") } - let(:auth_plain) { credential&.to_smtp_plain } - let(:mail_from) { "test@example.com" } - let(:rcpt_to) { "test@example.com" } - - before do - client.handle("HELO test.example.com") - client.handle("AUTH PLAIN #{auth_plain}") if auth_plain - client.handle("MAIL FROM: #{mail_from}") - client.handle("RCPT TO: #{rcpt_to}") - end - - describe "when finished sending data" do - context "when the data is larger than the maximum message size" do - it "returns an error and resets the state" do - allow(Postal.config.smtp_server).to receive(:max_message_size).and_return(1) - client.handle("DATA") - client.handle("a" * 1024 * 1024 * 10) - expect(client.handle(".")).to eq "552 Message too large (maximum size 1MB)" - end - end - - context "when a loop is detected" do - it "returns an error and resets the state" do - client.handle("DATA") - client.handle("Received: from example1.com by #{Postal.config.dns.smtp_server_hostname}") - client.handle("Received: from example2.com by #{Postal.config.dns.smtp_server_hostname}") - client.handle("Received: from example1.com by #{Postal.config.dns.smtp_server_hostname}") - client.handle("Received: from example2.com by #{Postal.config.dns.smtp_server_hostname}") - client.handle("Subject: Test") - client.handle("From: #{mail_from}") - client.handle("To: #{rcpt_to}") - client.handle("") - client.handle("This is a test message") - expect(client.handle(".")).to eq "550 Loop detected" - end - end - - context "when the email content is not suitable for the credential" do - it "returns an error and resets the state" do - client.handle("DATA") - client.handle("Subject: Test") - client.handle("From: invalid@krystal.uk") - client.handle("To: #{rcpt_to}") - client.handle("") - client.handle("This is a test message") - expect(client.handle(".")).to eq "530 From/Sender name is not valid" - end - end - - context "when sending an outgoing email" do - let(:domain) { create(:domain, owner: server) } - let(:mail_from) { "test@#{domain.name}" } - let(:auth_plain) { credential.to_smtp_plain } - - it "stores the message and resets the state" do - client.handle("DATA") - client.handle("Subject: Test") - client.handle("From: #{mail_from}") - client.handle("To: #{rcpt_to}") - client.handle("") - client.handle("This is a test message") - expect(client.handle(".")).to eq "250 OK" - queued_message = QueuedMessage.first - expect(queued_message).to have_attributes( - domain: "example.com", - server: server - ) - - expect(server.message(queued_message.message_id)).to have_attributes( - mail_from: mail_from, - rcpt_to: rcpt_to, - subject: "Test", - scope: "outgoing", - route_id: nil, - credential_id: credential.id, - raw_headers: kind_of(String), - raw_message: kind_of(String) - ) - end - end - - context "when sending a bounce message" do - let(:credential) { nil } - let(:rcpt_to) { "#{server.token}@#{Postal.config.dns.return_path}" } - - context "when there is a return path route" do - let(:domain) { create(:domain, owner: server) } - - before do - endpoint = create(:http_endpoint, server: server) - create(:route, domain: domain, server: server, name: "__returnpath__", mode: "Endpoint", endpoint: endpoint) - end - - it "stores the message for the return path route and resets the state" do - client.handle("DATA") - client.handle("Subject: Bounce: Test") - client.handle("From: #{mail_from}") - client.handle("To: #{rcpt_to}") - client.handle("") - client.handle("This is a test message") - expect(client.handle(".")).to eq "250 OK" - - queued_message = QueuedMessage.first - expect(queued_message).to have_attributes( - domain: Postal.config.dns.return_path, - server: server - ) - - expect(server.message(queued_message.message_id)).to have_attributes( - mail_from: mail_from, - rcpt_to: rcpt_to, - subject: "Bounce: Test", - scope: "incoming", - route_id: server.routes.first.id, - domain_id: domain.id, - credential_id: nil, - raw_headers: kind_of(String), - raw_message: kind_of(String), - bounce: true - ) - end - end - - context "when there is no return path route" do - it "stores the message normally and resets the state" do - client.handle("DATA") - client.handle("Subject: Bounce: Test") - client.handle("From: #{mail_from}") - client.handle("To: #{rcpt_to}") - client.handle("") - client.handle("This is a test message") - expect(client.handle(".")).to eq "250 OK" - - queued_message = QueuedMessage.first - expect(queued_message).to have_attributes( - domain: Postal.config.dns.return_path, - server: server - ) - - expect(server.message(queued_message.message_id)).to have_attributes( - mail_from: mail_from, - rcpt_to: rcpt_to, - subject: "Bounce: Test", - scope: "incoming", - route_id: nil, - domain_id: nil, - credential_id: nil, - raw_headers: kind_of(String), - raw_message: kind_of(String), - bounce: true - ) - end - end - end - - context "when receiving an incoming email" do - let(:domain) { create(:domain, owner: server) } - let(:route) { create(:route, server: server, domain: domain) } - - let(:credential) { nil } - let(:rcpt_to) { "#{route.name}@#{domain.name}" } - - it "stores the message and resets the state" do - client.handle("DATA") - client.handle("Subject: Test") - client.handle("From: #{mail_from}") - client.handle("To: #{rcpt_to}") - client.handle("") - client.handle("This is a test message") - expect(client.handle(".")).to eq "250 OK" - - queued_message = QueuedMessage.first - expect(queued_message).to have_attributes( - domain: domain.name, - server: server - ) - - expect(server.message(queued_message.message_id)).to have_attributes( - mail_from: mail_from, - rcpt_to: rcpt_to, - subject: "Test", - scope: "incoming", - route_id: route.id, - domain_id: domain.id, - credential_id: nil, - raw_headers: kind_of(String), - raw_message: kind_of(String) - ) - end - end - end - end - - end -end diff --git a/spec/lib/postal/smtp_server/client/helo_spec.rb b/spec/lib/postal/smtp_server/client/helo_spec.rb deleted file mode 100644 index 85d513b..0000000 --- a/spec/lib/postal/smtp_server/client/helo_spec.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -module Postal - module SMTPServer - - describe Client do - let(:ip_address) { "1.2.3.4" } - subject(:client) { described_class.new(ip_address) } - - describe "HELO" do - it "returns the hostname" do - expect(client.state).to eq :welcome - expect(client.handle("HELO: test.example.com")).to eq "250 #{Postal.config.dns.smtp_server_hostname}" - expect(client.state).to eq :welcomed - end - end - - describe "EHLO" do - it "returns the capabilities" do - expect(client.handle("EHLO test.example.com")).to eq ["250-My capabilities are", - "250 AUTH CRAM-MD5 PLAIN LOGIN"] - end - - context "when TLS is enabled" do - it "returns capabilities include starttls" do - allow(Postal.config.smtp_server).to receive(:tls_enabled?).and_return(true) - expect(client.handle("EHLO test.example.com")).to eq ["250-My capabilities are", - "250-STARTTLS", - "250 AUTH CRAM-MD5 PLAIN LOGIN"] - end - end - end - end - - end -end diff --git a/spec/lib/postal/smtp_server/client/mail_from_spec.rb b/spec/lib/postal/smtp_server/client/mail_from_spec.rb deleted file mode 100644 index b62de45..0000000 --- a/spec/lib/postal/smtp_server/client/mail_from_spec.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -module Postal - module SMTPServer - - describe Client do - let(:ip_address) { "1.2.3.4" } - subject(:client) { described_class.new(ip_address) } - - describe "MAIL FROM" do - it "returns an error if no HELO is provided" do - expect(client.handle("MAIL FROM: test@example.com")).to eq "503 EHLO/HELO first please" - expect(client.state).to eq :welcome - end - - it "resets the transaction when called" do - expect(client).to receive(:transaction_reset).and_call_original.at_least(3).times - client.handle("HELO test.example.com") - client.handle("MAIL FROM: test@example.com") - client.handle("MAIL FROM: test2@example.com") - end - - it "sets the mail from address" do - client.handle("HELO test.example.com") - expect(client.handle("MAIL FROM: test@example.com")).to eq "250 OK" - expect(client.state).to eq :mail_from_received - expect(client.instance_variable_get("@mail_from")).to eq "test@example.com" - end - end - end - - end -end diff --git a/spec/lib/postal/smtp_server/client/rcpt_to_spec.rb b/spec/lib/postal/smtp_server/client/rcpt_to_spec.rb deleted file mode 100644 index ee35abb..0000000 --- a/spec/lib/postal/smtp_server/client/rcpt_to_spec.rb +++ /dev/null @@ -1,172 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -module Postal - module SMTPServer - - describe Client do - let(:ip_address) { "1.2.3.4" } - subject(:client) { described_class.new(ip_address) } - - describe "RCPT TO" do - let(:helo) { "test.example.com" } - let(:mail_from) { "test@example.com" } - - before do - client.handle("HELO #{helo}") - client.handle("MAIL FROM: #{mail_from}") if mail_from - end - - context "when MAIL FROM has not been sent" do - let(:mail_from) { nil } - - it "returns an error if RCPT TO is sent before MAIL FROM" do - expect(client.handle("RCPT TO: no-route-here@internal.com")).to eq "503 EHLO/HELO and MAIL FROM first please" - expect(client.state).to eq :welcomed - end - end - - it "returns an error if RCPT TO is not valid" do - expect(client.handle("RCPT TO: blah")).to eq "501 Invalid RCPT TO" - end - - it "returns an error if RCPT TO is empty" do - expect(client.handle("RCPT TO: ")).to eq "501 RCPT TO should not be empty" - end - - context "when the RCPT TO address is the system return path host" do - it "returns an error if the server does not exist" do - expect(client.handle("RCPT TO: nothing@#{Postal.config.dns.return_path}")).to eq "550 Invalid server token" - end - - it "returns an error if the server is suspended" do - server = create(:server, :suspended) - expect(client.handle("RCPT TO: #{server.token}@#{Postal.config.dns.return_path}")) - .to eq "535 Mail server has been suspended" - end - - it "adds a recipient if all OK" do - server = create(:server) - address = "#{server.token}@#{Postal.config.dns.return_path}" - expect(client.handle("RCPT TO: #{address}")).to eq "250 OK" - expect(client.recipients).to eq [[:bounce, address, server]] - expect(client.state).to eq :rcpt_to_received - end - end - - context "when the RCPT TO address is on a host using the return path prefix" do - it "returns an error if the server does not exist" do - address = "nothing@#{Postal.config.dns.custom_return_path_prefix}.example.com" - expect(client.handle("RCPT TO: #{address}")).to eq "550 Invalid server token" - end - - it "returns an error if the server is suspended" do - server = create(:server, :suspended) - address = "#{server.token}@#{Postal.config.dns.custom_return_path_prefix}.example.com" - expect(client.handle("RCPT TO: #{address}")).to eq "535 Mail server has been suspended" - end - - it "adds a recipient if all OK" do - server = create(:server) - address = "#{server.token}@#{Postal.config.dns.custom_return_path_prefix}.example.com" - expect(client.handle("RCPT TO: #{address}")).to eq "250 OK" - expect(client.recipients).to eq [[:bounce, address, server]] - expect(client.state).to eq :rcpt_to_received - end - end - - context "when the RCPT TO address is within the route domain" do - it "returns an error if the route token is invalid" do - address = "nothing@#{Postal.config.dns.route_domain}" - expect(client.handle("RCPT TO: #{address}")).to eq "550 Invalid route token" - end - - it "returns an error if the server is suspended" do - server = create(:server, :suspended) - route = create(:route, server: server) - address = "#{route.token}@#{Postal.config.dns.route_domain}" - expect(client.handle("RCPT TO: #{address}")).to eq "535 Mail server has been suspended" - end - - it "returns an error if the route is set to Reject mail" do - server = create(:server) - route = create(:route, server: server, mode: "Reject") - address = "#{route.token}@#{Postal.config.dns.route_domain}" - expect(client.handle("RCPT TO: #{address}")).to eq "550 Route does not accept incoming messages" - end - - it "adds a recipient if all OK" do - server = create(:server) - route = create(:route, server: server) - address = "#{route.token}+tag1@#{Postal.config.dns.route_domain}" - expect(client.handle("RCPT TO: #{address}")).to eq "250 OK" - expect(client.recipients).to eq [[:route, "#{route.name}+tag1@#{route.domain.name}", server, { route: route }]] - expect(client.state).to eq :rcpt_to_received - end - end - - context "when authenticated and the RCPT TO address is provided" do - it "returns an error if the server is suspended" do - server = create(:server, :suspended) - credential = create(:credential, server: server, type: "SMTP") - expect(client.handle("AUTH PLAIN #{credential.to_smtp_plain}")).to match(/235 Granted for /) - expect(client.handle("RCPT TO: outgoing@example.com")).to eq "535 Mail server has been suspended" - end - - it "adds a recipient if all OK" do - server = create(:server) - credential = create(:credential, server: server, type: "SMTP") - expect(client.handle("AUTH PLAIN #{credential.to_smtp_plain}")).to match(/235 Granted for /) - expect(client.handle("RCPT TO: outgoing@example.com")).to eq "250 OK" - expect(client.recipients).to eq [[:credential, "outgoing@example.com", server]] - expect(client.state).to eq :rcpt_to_received - end - end - - context "when not authenticated and the RCPT TO address is a route" do - it "returns an error if the server is suspended" do - server = create(:server, :suspended) - route = create(:route, server: server) - address = "#{route.name}@#{route.domain.name}" - expect(client.handle("RCPT TO: #{address}")).to eq "535 Mail server has been suspended" - end - - it "returns an error if the route is set to Reject mail" do - server = create(:server) - route = create(:route, server: server, mode: "Reject") - address = "#{route.name}@#{route.domain.name}" - expect(client.handle("RCPT TO: #{address}")).to eq "550 Route does not accept incoming messages" - end - - it "adds a recipient if all OK" do - server = create(:server) - route = create(:route, server: server) - address = "#{route.name}@#{route.domain.name}" - expect(client.handle("RCPT TO: #{address}")).to eq "250 OK" - expect(client.recipients).to eq [[:route, address, server, { route: route }]] - expect(client.state).to eq :rcpt_to_received - end - end - - context "when not authenticated and RCPT TO does not match a route" do - it "returns an error" do - expect(client.handle("RCPT TO: nothing@nothing.com")).to eq "530 Authentication required" - end - - context "when the connecting IP has an credential" do - it "adds a recipient" do - server = create(:server) - create(:credential, server: server, type: "SMTP-IP", key: "1.0.0.0/8") - address = "test@example.com" - expect(client.handle("RCPT TO: #{address}")).to eq "250 OK" - expect(client.recipients).to eq [[:credential, address, server]] - expect(client.state).to eq :rcpt_to_received - end - end - end - end - end - - end -end diff --git a/spec/lib/postal/smtp_server/client_spec.rb b/spec/lib/postal/smtp_server/client_spec.rb deleted file mode 100644 index 0c9261b..0000000 --- a/spec/lib/postal/smtp_server/client_spec.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -module Postal - module SMTPServer - - describe Client do - let(:ip_address) { "1.2.3.4" } - subject(:client) { described_class.new(ip_address) } - end - - end -end diff --git a/spec/lib/smtp_server/client/auth_spec.rb b/spec/lib/smtp_server/client/auth_spec.rb new file mode 100644 index 0000000..edf9e50 --- /dev/null +++ b/spec/lib/smtp_server/client/auth_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require "rails_helper" + +module SMTPServer + + describe Client do + let(:ip_address) { "1.2.3.4" } + subject(:client) { described_class.new(ip_address) } + + before do + client.handle("HELO test.example.com") + end + + describe "AUTH PLAIN" do + context "when no credentials are provided on the initial data" do + it "returns a 334" do + expect(client.handle("AUTH PLAIN")).to eq("334") + end + + it "accepts the username and password from the next input" do + client.handle("AUTH PLAIN") + credential = create(:credential, type: "SMTP") + expect(client.handle(credential.to_smtp_plain)).to match(/235 Granted for/) + end + end + + context "when valid credentials are provided on one line" do + it "authenticates and returns a response" do + credential = create(:credential, type: "SMTP") + expect(client.handle("AUTH PLAIN #{credential.to_smtp_plain}")).to match(/235 Granted for/) + expect(client.credential).to eq credential + end + end + + context "when invalid credentials are provided" do + it "returns an error and resets the state" do + base64 = Base64.encode64("user\0pass") + expect(client.handle("AUTH PLAIN #{base64}")).to eq("535 Invalid credential") + expect(client.state).to eq :welcomed + end + end + + context "when username or password is missing" do + it "returns an error and resets the state" do + base64 = Base64.encode64("pass") + expect(client.handle("AUTH PLAIN #{base64}")).to eq("535 Authenticated failed - protocol error") + expect(client.state).to eq :welcomed + end + end + end + + describe "AUTH LOGIN" do + context "when no username is provided on the first line" do + it "requests the username" do + expect(client.handle("AUTH LOGIN")).to eq("334 VXNlcm5hbWU6") + end + end + + context "when a username is provided on the first line" do + it "requests a password" do + username = Base64.encode64("xx") + expect(client.handle("AUTH LOGIN #{username}")).to eq("334 UGFzc3dvcmQ6") + end + + it "authenticates and returns a response" do + credential = create(:credential, type: "SMTP") + username = Base64.encode64("xx") + password = Base64.encode64(credential.key) + expect(client.handle("AUTH LOGIN #{username}")).to eq("334 UGFzc3dvcmQ6") + expect(client.handle(password)).to match(/235 Granted for/) + expect(client.credential).to eq credential + end + end + + context "when invalid credentials are provided" do + it "returns an error and resets the state" do + username = Base64.encode64("xx") + password = Base64.encode64("xx") + expect(client.handle("AUTH LOGIN #{username}")).to eq("334 UGFzc3dvcmQ6") + expect(client.handle(password)).to eq("535 Invalid credential") + expect(client.state).to eq :welcomed + end + end + end + + describe "AUTH CRAM-MD5" do + context "when valid credentials are provided" do + it "authenticates and returns a response" do + credential = create(:credential, type: "SMTP") + result = client.handle("AUTH CRAM-MD5") + expect(result).to match(/\A334 [A-Za-z0-9=]+\z/) + challenge = Base64.decode64(result.split[1]) + password = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("md5"), credential.key, challenge) + base64 = Base64.encode64("#{credential.server.organization.permalink}/#{credential.server.permalink} #{password}") + expect(client.handle(base64)).to match(/235 Granted for/) + expect(client.credential).to eq credential + end + end + + context "when no org/server matches the provided username" do + it "returns an error" do + client.handle("AUTH CRAM-MD5") + base64 = Base64.encode64("org/server password") + expect(client.handle(base64)).to eq "535 Denied" + end + end + + context "when invalid credentials are provided" do + it "returns an error and resets the state" do + server = create(:server) + base64 = Base64.encode64("#{server.organization.permalink}/#{server.permalink} invalid-password") + client.handle("AUTH CRAM-MD5") + expect(client.handle(base64)).to eq("535 Denied") + end + end + end + end + +end diff --git a/spec/lib/smtp_server/client/data_spec.rb b/spec/lib/smtp_server/client/data_spec.rb new file mode 100644 index 0000000..66ae280 --- /dev/null +++ b/spec/lib/smtp_server/client/data_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require "rails_helper" + +module SMTPServer + + describe Client do + let(:ip_address) { "1.2.3.4" } + subject(:client) { described_class.new(ip_address) } + + describe "DATA" do + it "returns an error if no helo" do + expect(client.handle("DATA")).to eq "503 HELO/EHLO, MAIL FROM and RCPT TO before sending data" + end + + it "returns an error if no mail from" do + client.handle("HELO test.example.com") + expect(client.handle("DATA")).to eq "503 HELO/EHLO, MAIL FROM and RCPT TO before sending data" + end + + it "returns an error if no rcpt to" do + client.handle("HELO test.example.com") + client.handle("MAIL FROM: test@example.com") + expect(client.handle("DATA")).to eq "503 HELO/EHLO, MAIL FROM and RCPT TO before sending data" + end + + it "returns go ahead" do + route = create(:route) + client.handle("HELO test.example.com") + client.handle("MAIL FROM: test@test.com") + client.handle("RCPT TO: #{route.name}@#{route.domain.name}") + expect(client.handle("DATA")).to eq "354 Go ahead" + end + + it "adds a received header for itself" do + route = create(:route) + client.handle("HELO test.example.com") + client.handle("MAIL FROM: test@test.com") + client.handle("RCPT TO: #{route.name}@#{route.domain.name}") + Timecop.freeze do + client.handle("DATA") + expect(client.headers["received"]).to include "from test.example.com (1.2.3.4 [1.2.3.4]) by postal.example.com with SMTP; #{Time.now.utc.rfc2822}" + end + end + + describe "subsequent commands" do + let(:route) { create(:route) } + before do + client.handle("HELO test.example.com") + client.handle("MAIL FROM: test@test.com") + client.handle("RCPT TO: #{route.name}@#{route.domain.name}") + end + + it "logs headers" do + client.handle("DATA") + client.handle("Subject: Test") + client.handle("From: test@test.com") + client.handle("To: test1@example.com") + client.handle("To: test2@example.com") + client.handle("X-Something: abcdef1234") + expect(client.headers["subject"]).to eq ["Test"] + expect(client.headers["from"]).to eq ["test@test.com"] + expect(client.headers["to"]).to eq ["test1@example.com", "test2@example.com"] + expect(client.headers["x-something"]).to eq ["abcdef1234"] + end + + it "logs content" do + Timecop.freeze do + client.handle("DATA") + client.handle("Subject: Test") + client.handle("") + client.handle("This is some content for the message.") + client.handle("It will keep going.") + expect(client.instance_variable_get("@data")).to eq <<~DATA + Received: from test.example.com (1.2.3.4 [1.2.3.4]) by #{Postal.config.dns.smtp_server_hostname} with SMTP; #{Time.now.utc.rfc2822}\r + Subject: Test\r + \r + This is some content for the message.\r + It will keep going.\r + DATA + end + end + end + end + end + +end diff --git a/spec/lib/smtp_server/client/finished_spec.rb b/spec/lib/smtp_server/client/finished_spec.rb new file mode 100644 index 0000000..5985fad --- /dev/null +++ b/spec/lib/smtp_server/client/finished_spec.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true + +require "rails_helper" + +module SMTPServer + + describe Client do + let(:ip_address) { "1.2.3.4" } + let(:server) { create(:server) } + subject(:client) { described_class.new(ip_address) } + + let(:credential) { create(:credential, server: server, type: "SMTP") } + let(:auth_plain) { credential&.to_smtp_plain } + let(:mail_from) { "test@example.com" } + let(:rcpt_to) { "test@example.com" } + + before do + client.handle("HELO test.example.com") + client.handle("AUTH PLAIN #{auth_plain}") if auth_plain + client.handle("MAIL FROM: #{mail_from}") + client.handle("RCPT TO: #{rcpt_to}") + end + + describe "when finished sending data" do + context "when the data is larger than the maximum message size" do + it "returns an error and resets the state" do + allow(Postal.config.smtp_server).to receive(:max_message_size).and_return(1) + client.handle("DATA") + client.handle("a" * 1024 * 1024 * 10) + expect(client.handle(".")).to eq "552 Message too large (maximum size 1MB)" + end + end + + context "when a loop is detected" do + it "returns an error and resets the state" do + client.handle("DATA") + client.handle("Received: from example1.com by #{Postal.config.dns.smtp_server_hostname}") + client.handle("Received: from example2.com by #{Postal.config.dns.smtp_server_hostname}") + client.handle("Received: from example1.com by #{Postal.config.dns.smtp_server_hostname}") + client.handle("Received: from example2.com by #{Postal.config.dns.smtp_server_hostname}") + client.handle("Subject: Test") + client.handle("From: #{mail_from}") + client.handle("To: #{rcpt_to}") + client.handle("") + client.handle("This is a test message") + expect(client.handle(".")).to eq "550 Loop detected" + end + end + + context "when the email content is not suitable for the credential" do + it "returns an error and resets the state" do + client.handle("DATA") + client.handle("Subject: Test") + client.handle("From: invalid@krystal.uk") + client.handle("To: #{rcpt_to}") + client.handle("") + client.handle("This is a test message") + expect(client.handle(".")).to eq "530 From/Sender name is not valid" + end + end + + context "when sending an outgoing email" do + let(:domain) { create(:domain, owner: server) } + let(:mail_from) { "test@#{domain.name}" } + let(:auth_plain) { credential.to_smtp_plain } + + it "stores the message and resets the state" do + client.handle("DATA") + client.handle("Subject: Test") + client.handle("From: #{mail_from}") + client.handle("To: #{rcpt_to}") + client.handle("") + client.handle("This is a test message") + expect(client.handle(".")).to eq "250 OK" + queued_message = QueuedMessage.first + expect(queued_message).to have_attributes( + domain: "example.com", + server: server + ) + + expect(server.message(queued_message.message_id)).to have_attributes( + mail_from: mail_from, + rcpt_to: rcpt_to, + subject: "Test", + scope: "outgoing", + route_id: nil, + credential_id: credential.id, + raw_headers: kind_of(String), + raw_message: kind_of(String) + ) + end + end + + context "when sending a bounce message" do + let(:credential) { nil } + let(:rcpt_to) { "#{server.token}@#{Postal.config.dns.return_path}" } + + context "when there is a return path route" do + let(:domain) { create(:domain, owner: server) } + + before do + endpoint = create(:http_endpoint, server: server) + create(:route, domain: domain, server: server, name: "__returnpath__", mode: "Endpoint", endpoint: endpoint) + end + + it "stores the message for the return path route and resets the state" do + client.handle("DATA") + client.handle("Subject: Bounce: Test") + client.handle("From: #{mail_from}") + client.handle("To: #{rcpt_to}") + client.handle("") + client.handle("This is a test message") + expect(client.handle(".")).to eq "250 OK" + + queued_message = QueuedMessage.first + expect(queued_message).to have_attributes( + domain: Postal.config.dns.return_path, + server: server + ) + + expect(server.message(queued_message.message_id)).to have_attributes( + mail_from: mail_from, + rcpt_to: rcpt_to, + subject: "Bounce: Test", + scope: "incoming", + route_id: server.routes.first.id, + domain_id: domain.id, + credential_id: nil, + raw_headers: kind_of(String), + raw_message: kind_of(String), + bounce: true + ) + end + end + + context "when there is no return path route" do + it "stores the message normally and resets the state" do + client.handle("DATA") + client.handle("Subject: Bounce: Test") + client.handle("From: #{mail_from}") + client.handle("To: #{rcpt_to}") + client.handle("") + client.handle("This is a test message") + expect(client.handle(".")).to eq "250 OK" + + queued_message = QueuedMessage.first + expect(queued_message).to have_attributes( + domain: Postal.config.dns.return_path, + server: server + ) + + expect(server.message(queued_message.message_id)).to have_attributes( + mail_from: mail_from, + rcpt_to: rcpt_to, + subject: "Bounce: Test", + scope: "incoming", + route_id: nil, + domain_id: nil, + credential_id: nil, + raw_headers: kind_of(String), + raw_message: kind_of(String), + bounce: true + ) + end + end + end + + context "when receiving an incoming email" do + let(:domain) { create(:domain, owner: server) } + let(:route) { create(:route, server: server, domain: domain) } + + let(:credential) { nil } + let(:rcpt_to) { "#{route.name}@#{domain.name}" } + + it "stores the message and resets the state" do + client.handle("DATA") + client.handle("Subject: Test") + client.handle("From: #{mail_from}") + client.handle("To: #{rcpt_to}") + client.handle("") + client.handle("This is a test message") + expect(client.handle(".")).to eq "250 OK" + + queued_message = QueuedMessage.first + expect(queued_message).to have_attributes( + domain: domain.name, + server: server + ) + + expect(server.message(queued_message.message_id)).to have_attributes( + mail_from: mail_from, + rcpt_to: rcpt_to, + subject: "Test", + scope: "incoming", + route_id: route.id, + domain_id: domain.id, + credential_id: nil, + raw_headers: kind_of(String), + raw_message: kind_of(String) + ) + end + end + end + end + +end diff --git a/spec/lib/smtp_server/client/helo_spec.rb b/spec/lib/smtp_server/client/helo_spec.rb new file mode 100644 index 0000000..7a39b8e --- /dev/null +++ b/spec/lib/smtp_server/client/helo_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails_helper" + +module SMTPServer + + describe Client do + let(:ip_address) { "1.2.3.4" } + subject(:client) { described_class.new(ip_address) } + + describe "HELO" do + it "returns the hostname" do + expect(client.state).to eq :welcome + expect(client.handle("HELO: test.example.com")).to eq "250 #{Postal.config.dns.smtp_server_hostname}" + expect(client.state).to eq :welcomed + end + end + + describe "EHLO" do + it "returns the capabilities" do + expect(client.handle("EHLO test.example.com")).to eq ["250-My capabilities are", + "250 AUTH CRAM-MD5 PLAIN LOGIN"] + end + + context "when TLS is enabled" do + it "returns capabilities include starttls" do + allow(Postal.config.smtp_server).to receive(:tls_enabled?).and_return(true) + expect(client.handle("EHLO test.example.com")).to eq ["250-My capabilities are", + "250-STARTTLS", + "250 AUTH CRAM-MD5 PLAIN LOGIN"] + end + end + end + end + +end diff --git a/spec/lib/smtp_server/client/mail_from_spec.rb b/spec/lib/smtp_server/client/mail_from_spec.rb new file mode 100644 index 0000000..e00aaa0 --- /dev/null +++ b/spec/lib/smtp_server/client/mail_from_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "rails_helper" + +module SMTPServer + + describe Client do + let(:ip_address) { "1.2.3.4" } + subject(:client) { described_class.new(ip_address) } + + describe "MAIL FROM" do + it "returns an error if no HELO is provided" do + expect(client.handle("MAIL FROM: test@example.com")).to eq "503 EHLO/HELO first please" + expect(client.state).to eq :welcome + end + + it "resets the transaction when called" do + expect(client).to receive(:transaction_reset).and_call_original.at_least(3).times + client.handle("HELO test.example.com") + client.handle("MAIL FROM: test@example.com") + client.handle("MAIL FROM: test2@example.com") + end + + it "sets the mail from address" do + client.handle("HELO test.example.com") + expect(client.handle("MAIL FROM: test@example.com")).to eq "250 OK" + expect(client.state).to eq :mail_from_received + expect(client.instance_variable_get("@mail_from")).to eq "test@example.com" + end + end + end + +end diff --git a/spec/lib/smtp_server/client/rcpt_to_spec.rb b/spec/lib/smtp_server/client/rcpt_to_spec.rb new file mode 100644 index 0000000..7325486 --- /dev/null +++ b/spec/lib/smtp_server/client/rcpt_to_spec.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +require "rails_helper" + +module SMTPServer + + describe Client do + let(:ip_address) { "1.2.3.4" } + subject(:client) { described_class.new(ip_address) } + + describe "RCPT TO" do + let(:helo) { "test.example.com" } + let(:mail_from) { "test@example.com" } + + before do + client.handle("HELO #{helo}") + client.handle("MAIL FROM: #{mail_from}") if mail_from + end + + context "when MAIL FROM has not been sent" do + let(:mail_from) { nil } + + it "returns an error if RCPT TO is sent before MAIL FROM" do + expect(client.handle("RCPT TO: no-route-here@internal.com")).to eq "503 EHLO/HELO and MAIL FROM first please" + expect(client.state).to eq :welcomed + end + end + + it "returns an error if RCPT TO is not valid" do + expect(client.handle("RCPT TO: blah")).to eq "501 Invalid RCPT TO" + end + + it "returns an error if RCPT TO is empty" do + expect(client.handle("RCPT TO: ")).to eq "501 RCPT TO should not be empty" + end + + context "when the RCPT TO address is the system return path host" do + it "returns an error if the server does not exist" do + expect(client.handle("RCPT TO: nothing@#{Postal.config.dns.return_path}")).to eq "550 Invalid server token" + end + + it "returns an error if the server is suspended" do + server = create(:server, :suspended) + expect(client.handle("RCPT TO: #{server.token}@#{Postal.config.dns.return_path}")) + .to eq "535 Mail server has been suspended" + end + + it "adds a recipient if all OK" do + server = create(:server) + address = "#{server.token}@#{Postal.config.dns.return_path}" + expect(client.handle("RCPT TO: #{address}")).to eq "250 OK" + expect(client.recipients).to eq [[:bounce, address, server]] + expect(client.state).to eq :rcpt_to_received + end + end + + context "when the RCPT TO address is on a host using the return path prefix" do + it "returns an error if the server does not exist" do + address = "nothing@#{Postal.config.dns.custom_return_path_prefix}.example.com" + expect(client.handle("RCPT TO: #{address}")).to eq "550 Invalid server token" + end + + it "returns an error if the server is suspended" do + server = create(:server, :suspended) + address = "#{server.token}@#{Postal.config.dns.custom_return_path_prefix}.example.com" + expect(client.handle("RCPT TO: #{address}")).to eq "535 Mail server has been suspended" + end + + it "adds a recipient if all OK" do + server = create(:server) + address = "#{server.token}@#{Postal.config.dns.custom_return_path_prefix}.example.com" + expect(client.handle("RCPT TO: #{address}")).to eq "250 OK" + expect(client.recipients).to eq [[:bounce, address, server]] + expect(client.state).to eq :rcpt_to_received + end + end + + context "when the RCPT TO address is within the route domain" do + it "returns an error if the route token is invalid" do + address = "nothing@#{Postal.config.dns.route_domain}" + expect(client.handle("RCPT TO: #{address}")).to eq "550 Invalid route token" + end + + it "returns an error if the server is suspended" do + server = create(:server, :suspended) + route = create(:route, server: server) + address = "#{route.token}@#{Postal.config.dns.route_domain}" + expect(client.handle("RCPT TO: #{address}")).to eq "535 Mail server has been suspended" + end + + it "returns an error if the route is set to Reject mail" do + server = create(:server) + route = create(:route, server: server, mode: "Reject") + address = "#{route.token}@#{Postal.config.dns.route_domain}" + expect(client.handle("RCPT TO: #{address}")).to eq "550 Route does not accept incoming messages" + end + + it "adds a recipient if all OK" do + server = create(:server) + route = create(:route, server: server) + address = "#{route.token}+tag1@#{Postal.config.dns.route_domain}" + expect(client.handle("RCPT TO: #{address}")).to eq "250 OK" + expect(client.recipients).to eq [[:route, "#{route.name}+tag1@#{route.domain.name}", server, { route: route }]] + expect(client.state).to eq :rcpt_to_received + end + end + + context "when authenticated and the RCPT TO address is provided" do + it "returns an error if the server is suspended" do + server = create(:server, :suspended) + credential = create(:credential, server: server, type: "SMTP") + expect(client.handle("AUTH PLAIN #{credential.to_smtp_plain}")).to match(/235 Granted for /) + expect(client.handle("RCPT TO: outgoing@example.com")).to eq "535 Mail server has been suspended" + end + + it "adds a recipient if all OK" do + server = create(:server) + credential = create(:credential, server: server, type: "SMTP") + expect(client.handle("AUTH PLAIN #{credential.to_smtp_plain}")).to match(/235 Granted for /) + expect(client.handle("RCPT TO: outgoing@example.com")).to eq "250 OK" + expect(client.recipients).to eq [[:credential, "outgoing@example.com", server]] + expect(client.state).to eq :rcpt_to_received + end + end + + context "when not authenticated and the RCPT TO address is a route" do + it "returns an error if the server is suspended" do + server = create(:server, :suspended) + route = create(:route, server: server) + address = "#{route.name}@#{route.domain.name}" + expect(client.handle("RCPT TO: #{address}")).to eq "535 Mail server has been suspended" + end + + it "returns an error if the route is set to Reject mail" do + server = create(:server) + route = create(:route, server: server, mode: "Reject") + address = "#{route.name}@#{route.domain.name}" + expect(client.handle("RCPT TO: #{address}")).to eq "550 Route does not accept incoming messages" + end + + it "adds a recipient if all OK" do + server = create(:server) + route = create(:route, server: server) + address = "#{route.name}@#{route.domain.name}" + expect(client.handle("RCPT TO: #{address}")).to eq "250 OK" + expect(client.recipients).to eq [[:route, address, server, { route: route }]] + expect(client.state).to eq :rcpt_to_received + end + end + + context "when not authenticated and RCPT TO does not match a route" do + it "returns an error" do + expect(client.handle("RCPT TO: nothing@nothing.com")).to eq "530 Authentication required" + end + + context "when the connecting IP has an credential" do + it "adds a recipient" do + server = create(:server) + create(:credential, server: server, type: "SMTP-IP", key: "1.0.0.0/8") + address = "test@example.com" + expect(client.handle("RCPT TO: #{address}")).to eq "250 OK" + expect(client.recipients).to eq [[:credential, address, server]] + expect(client.state).to eq :rcpt_to_received + end + end + end + end + end + +end diff --git a/spec/lib/smtp_server/client_spec.rb b/spec/lib/smtp_server/client_spec.rb new file mode 100644 index 0000000..353c12e --- /dev/null +++ b/spec/lib/smtp_server/client_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +module SMTPServer + + describe Client do + let(:ip_address) { "1.2.3.4" } + subject(:client) { described_class.new(ip_address) } + end + +end