diff --git a/Rakefile b/Rakefile index f9c54cc5..d26021d0 100644 --- a/Rakefile +++ b/Rakefile @@ -2,6 +2,7 @@ require "fileutils" require "rubygems" +require 'rspec/core/rake_task' require "mail_catcher/version" diff --git a/lib/mail_catcher.rb b/lib/mail_catcher.rb index a1fd54b3..ed465582 100644 --- a/lib/mail_catcher.rb +++ b/lib/mail_catcher.rb @@ -1,26 +1,22 @@ # frozen_string_literal: true +require 'async/io/address_endpoint' +require 'async/http/endpoint' +require 'async/websocket/adapters/rack' +require 'async/io/shared_endpoint' +require 'falcon' require "open3" require "optparse" require "rbconfig" - -require "eventmachine" -require "thin" - -module EventMachine - # Monkey patch fix for 10deb4 - # See https://github.com/eventmachine/eventmachine/issues/569 - def self.reactor_running? - (@reactor_running || false) - end -end +require 'socket' +require 'mail' require "mail_catcher/version" module MailCatcher extend self autoload :Bus, "mail_catcher/bus" autoload :Mail, "mail_catcher/mail" - autoload :Smtp, "mail_catcher/smtp" + autoload :SMTP, "mail_catcher/smtp" autoload :Web, "mail_catcher/web" def env @@ -173,75 +169,90 @@ def run! options=nil puts "Starting MailCatcher v#{VERSION}" - Thin::Logging.debug = development? - Thin::Logging.silent = !development? + Async.run do + @smtp_address = Async::IO::Address.tcp(options[:smtp_ip], options[:smtp_port]) + @smtp_endpoint = Async::IO::AddressEndpoint.new(@smtp_address) + @smtp_socket = rescue_port(options[:smtp_port]) { @smtp_endpoint.bind } + puts "==> #{smtp_url}" + + @http_address = Async::IO::Address.tcp(options[:http_ip], options[:http_port]) + @http_endpoint = Async::IO::AddressEndpoint.new(@http_address) + @http_socket = rescue_port(options[:http_port]) { @http_endpoint.bind } + puts "==> #{http_url}" + end - # One EventMachine loop... - EventMachine.run do - # Set up an SMTP server to run within EventMachine - rescue_port options[:smtp_port] do - EventMachine.start_server options[:smtp_ip], options[:smtp_port], Smtp - puts "==> #{smtp_url}" + Async.logger.level = :debug if options[:verbose] + + if options[:daemon] + if quittable? + puts "*** MailCatcher runs as a daemon by default. Go to the web interface to quit." + else + puts "*** MailCatcher is now running as a daemon that cannot be quit." end + Process.daemon + end - # Let Thin set itself up inside our EventMachine loop - # (Skinny/WebSockets just works on the inside) - rescue_port options[:http_port] do - Thin::Server.start(options[:http_ip], options[:http_port], Web) - puts "==> #{http_url}" + Async::Reactor.run do |task| + smtp_endpoint = MailCatcher::SMTP::URLEndpoint.new(URI.parse(smtp_url), @smtp_endpoint) + smtp_server = MailCatcher::SMTP::Server.new(smtp_endpoint) do |envelope| + MailCatcher::Mail.add_message(sender: envelope.sender, recipients: envelope.recipients, + source: envelope.content) end - # Open the web browser before detatching console - if options[:browse] - EventMachine.next_tick do - browse http_url + smtp_task = task.async do |task| + task.annotate "binding to #{@smtp_socket.local_address.inspect}" + + begin + @smtp_socket.listen(Socket::SOMAXCONN) + @smtp_socket.accept_each(task: task, &smtp_server.method(:accept)) + ensure + @smtp_socket.close end end - # Daemonize, if we should, but only after the servers have started. - if options[:daemon] - EventMachine.next_tick do - if quittable? - puts "*** MailCatcher runs as a daemon by default. Go to the web interface to quit." - else - puts "*** MailCatcher is now running as a daemon that cannot be quit." - end - Process.daemon + http_endpoint = Async::HTTP::Endpoint.new(URI.parse(http_url), @http_endpoint) + http_app = Falcon::Adapters::Rack.new(Web.app) + http_server = Falcon::Server.new(http_app, http_endpoint) + + task.async do |task| + task.annotate "binding to #{@http_socket.local_address.inspect}" + + begin + @http_socket.listen(Socket::SOMAXCONN) + @http_socket.accept_each(task: task, &http_server.method(:accept)) + ensure + @http_socket.close end end + + browse(http_url) if options[:browse] end + rescue Interrupt + # Cool story end def quit! - MailCatcher::Bus.push(type: "quit") + Async::Task.current.reactor.stop + end - EventMachine.next_tick { EventMachine.stop_event_loop } + def http_url + "http://#{@@options[:http_ip]}:#{@@options[:http_port]}#{@@options[:http_path]}" end -protected + protected def smtp_url "smtp://#{@@options[:smtp_ip]}:#{@@options[:smtp_port]}" end - def http_url - "http://#{@@options[:http_ip]}:#{@@options[:http_port]}#{@@options[:http_path]}".chomp("/") - end - def rescue_port port begin yield - - # XXX: EventMachine only spits out RuntimeError with a string description - rescue RuntimeError - if $!.to_s =~ /\bno acceptor\b/ - puts "~~> ERROR: Something's using port #{port}. Are you already running MailCatcher?" - puts "==> #{smtp_url}" - puts "==> #{http_url}" - exit -1 - else - raise - end + rescue Errno::EADDRINUSE + puts "~~> ERROR: Something's using port #{port}. Are you already running MailCatcher?" + puts "==> #{smtp_url}" + puts "==> #{http_url}" + exit(-1) end end end diff --git a/lib/mail_catcher/bus.rb b/lib/mail_catcher/bus.rb index 0c86fdf1..e3af681f 100644 --- a/lib/mail_catcher/bus.rb +++ b/lib/mail_catcher/bus.rb @@ -1,7 +1,48 @@ # frozen_string_literal: true -require "eventmachine" - module MailCatcher - Bus = EventMachine::Channel.new + # Async-friendly broadcast channel + class Channel + def initialize + @subscription_id = 0 + @subscriptions = {} + end + + def subscriber_count + @subscriptions.size + end + + def push(*values) + Async.run do + values.each do |value| + @subscriptions.each_value do |subscription| + Async do + subscription.call(value) + end + end + end + end + end + + def subscribe(&block) + subscription_id = next_subscription_id + + @subscriptions[subscription_id] = block + + subscription_id + end + + def unsubscribe(subscription_id) + @subscriptions.delete(subscription_id) + end + + private + + def next_subscription_id + @subscription_id += 1 + end + end + + # Then we instantiate a global one + Bus = Channel.new end diff --git a/lib/mail_catcher/mail.rb b/lib/mail_catcher/mail.rb index b7415ae2..a5080a64 100644 --- a/lib/mail_catcher/mail.rb +++ b/lib/mail_catcher/mail.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require "eventmachine" require "json" require "mail" require "sqlite3" @@ -56,10 +55,7 @@ def add_message(message) add_message_part(message_id, cid, part.mime_type || "text/plain", part.attachment? ? 1 : 0, part.filename, part.charset, body, body.length) end - EventMachine.next_tick do - message = MailCatcher::Mail.message message_id - MailCatcher::Bus.push(type: "add", message: message) - end + MailCatcher::Bus.push(type: "add", message: message(message_id)) end def add_message_part(*args) @@ -157,18 +153,14 @@ def delete! @delete_all_messages_query ||= db.prepare "DELETE FROM message" @delete_all_messages_query.execute - EventMachine.next_tick do - MailCatcher::Bus.push(type: "clear") - end + MailCatcher::Bus.push(type: "clear") end def delete_message!(message_id) @delete_messages_query ||= db.prepare "DELETE FROM message WHERE id = ?" @delete_messages_query.execute(message_id) - EventMachine.next_tick do - MailCatcher::Bus.push(type: "remove", id: message_id) - end + MailCatcher::Bus.push(type: "remove", id: message_id) end def delete_older_messages!(count = MailCatcher.options[:messages_limit]) diff --git a/lib/mail_catcher/smtp.rb b/lib/mail_catcher/smtp.rb index bfef3411..328079d3 100644 --- a/lib/mail_catcher/smtp.rb +++ b/lib/mail_catcher/smtp.rb @@ -1,68 +1,415 @@ # frozen_string_literal: true -require "eventmachine" +require "uri" -require "mail_catcher/mail" +require "async/io/endpoint" +require "async/io/host_endpoint" +require "async/io/ssl_endpoint" -class MailCatcher::Smtp < EventMachine::Protocols::SmtpServer - # We override EM's mail from processing to allow multiple mail-from commands - # per [RFC 2821](https://tools.ietf.org/html/rfc2821#section-4.1.1.2) - def process_mail_from sender - if @state.include? :mail_from - @state -= [:mail_from, :rcpt, :data] +module MailCatcher + module SMTP + class URLEndpoint < Async::IO::Endpoint + def self.parse(string, **options) + url = URI.parse(string).normalize - receive_reset + new(url, **options) + end + + def initialize(url, endpoint = nil, **options) + super(**options) + + raise ArgumentError, "URL must be absolute (include scheme, host): #{url}" unless url.absolute? + + @url = url + @endpoint = endpoint + end + + def to_s + @url.to_s + end + + attr :url, :options + + def address + endpoint.address + end + + def secure? + ['smtps'].include?(@url.scheme) + end + + def protocol + Protocol::SMTP + end + + def default_port + secure? ? 465 : 25 + end + + def default_port? + port == default_port + end + + def port + @options[:port] || @url.port || default_port + end + + def hostname + @options[:hostname] || @url.hostname + end + + def scheme + @options[:scheme] || @url.scheme + end + + def authority + if default_port? + hostname + else + "#{hostname}:#{port}" + end + end + + def path + @url.path + end + + LOCALHOST = 'localhost' + + # We don't try to validate peer certificates when talking to localhost because they would always be self-signed. + def ssl_verify_mode + case hostname + when LOCALHOST + OpenSSL::SSL::VERIFY_NONE + else + OpenSSL::SSL::VERIFY_PEER + end + end + + def ssl_context + @options[:ssl_context] || ::OpenSSL::SSL::SSLContext.new.tap do |context| + context.set_params( + verify_mode: ssl_verify_mode + ) + end + end + + def tcp_options + { reuse_port: @options[:reuse_port] ? true : false } + end + + def build_endpoint(endpoint = nil) + endpoint ||= Async::IO::Endpoint.tcp(hostname, port, tcp_options) + + if secure? + # Wrap it in SSL: + return Async::IO::SSLEndpoint.new(endpoint, + ssl_context: ssl_context, + hostname: hostname) + end + + endpoint + end + + def endpoint + @endpoint ||= build_endpoint + end + + def bind(*args, &block) + endpoint.bind(*args, &block) + end + + def connect(*args, &block) + endpoint.connect(*args, &block) + end + + def each + return to_enum unless block_given? + + endpoint.each do |endpoint| + yield self.class.new(@url, endpoint, @options) + end + end + + def key + [@url.scheme, @url.userinfo, @url.host, @url.port, @options] + end + + def eql?(other) + key.eql? other.key + end + + def hash + key.hash + end end - super - end + Envelope = Struct.new(:sender, :recipients, :content) - def current_message - @current_message ||= {} - end + require 'async/io/protocol/line' - def receive_reset - @current_message = nil + module Protocol + module SMTP + def self.server(stream, *args) + Server.new(stream, *args) + end - true - end + class Server < Async::IO::Protocol::Line + CR = "\r" + LF = "\n" + CRLF = "\r\n" + SP = ' ' + COLON = ':' + DOT = '.' - def receive_sender(sender) - # EventMachine SMTP advertises size extensions [https://tools.ietf.org/html/rfc1870] - # so strip potential " SIZE=..." suffixes from senders - sender = $` if sender =~ / SIZE=\d+\z/ + def initialize(stream, *args) + super(stream, CRLF) - current_message[:sender] = sender + @hostname = hostname + @state = {} + end - true - end + attr :hostname - def receive_recipient(recipient) - current_message[:recipients] ||= [] - current_message[:recipients] << recipient + def read_line + if line = @stream.read_until(LF) + line.chomp(CR) + else + @stream.eof! + end + end - true - end + alias write_line write_lines + + def write_response(code, *lines, last_line) + write_lines(*lines.map { |line| "#{code}-#{line}" }, + "#{code} #{last_line}") + end + + def each(task: Async::Task.current) + write_response 220, 'MailCatcher ready' - def receive_data_chunk(lines) - current_message[:source] ||= +"" + loop do + line = read_line + command, line = line.split(SP, 2) + case command.upcase + when 'HELO' + write_response 250, hostname + when 'EHLO' + write_response 250, hostname, '8BITMIME', 'BINARYMIME', 'SMTPUTF8' + when 'SEND' + write_response 502, 'Command not implemented' + when 'SOML' + write_response 502, 'Command not implemented' + when 'SAML' + write_response 502, 'Command not implemented' + when 'MAIL' + from, line = line.split(COLON, 2) + unless from == 'FROM' + write_response 500, 'Syntax error, command unrecognized' + next + end + sender, line = line.split(SP, 2) + encoding = nil + if line && !line.empty? + line.split(SP).each do |param| + case param + when 'BODY=7BIT' + encoding = :ascii + when 'BODY=8BITMIME' + encoding = :binary + when 'BODY=BINARYMIME' + encoding = :binary + when 'SMTPUTF8' + encoding = :utf8 + else + write_response 501, 'Unexpected parameters or arguments' + end + end + end + @state[:sender] = sender + @state[:encoding] = encoding if encoding + write_response 250, "New message from: #{sender}" + when 'RCPT' + unless @state[:sender] + write_response 503, 'Bad sequence of commands' + next + end + to, line = line.split(COLON, 2) + unless to == 'TO' + write_response 501, 'Syntax error in parameters or arguments' + next + end + recipient, line = line.split(SP, 2) + if line && !line.empty? + write_response 501, 'Unexpected parameters or arguments' + next + end + @state[:recipients] ||= [] + @state[:recipients] << recipient + write_response 250, "Recipient added: #{recipient}" + when 'DATA' + unless @state[:sender] && @state[:recipients] + write_response 503, 'Bad sequence of commands' + next + end + if @state.key? :buffer # BDAT + write_response 503, 'Bad sequence of commands' + next + end + if line && !line.empty? + write_response 501, 'Unexpected parameters or arguments' + next + end + write_response 354, 'Start mail input; end with .' + buffer = ''.b + loop do + line = read_line + break if line == DOT - lines.each do |line| - current_message[:source] << line << "\r\n" + line.delete_prefix!(DOT) + buffer << line << CRLF + task.yield + end + encoding = @state[:encoding] + begin + case encoding + when :ascii + buffer.force_encoding(Encoding::ASCII) + when :binary + buffer.force_encoding(Encoding::BINARY) + when :utf8, nil + buffer.force_encoding(Encoding::UTF_8) + end + rescue ArgumentError => e + write_response 501, "Incorrect encoding (#{encoding}): #{e}" + @state.clear + next + end + yield Envelope.new(@state[:sender], @state[:recipients], buffer) + @state.clear + write_response 250, 'Message sent' + when 'BDAT' + unless @state[:sender] && @state[:recipients] + write_response 503, 'Bad sequence of commands' + next + end + size, line = line.split(SP, 2) + unless size.to_i.to_s == size + write_response 501, 'Syntax error in parameters or arguments' + next + end + size = size.to_i + last = false + if line == 'LAST' + last = true + elsif line && !line.empty? + write_response 501, 'Unexpected parameters or arguments' + next + end + buffer = @state[:buffer] ||= ''.b + buffer << read(size) + if last + begin + case encoding + when :ascii + buffer.force_encoding(Encoding::ASCII) + when :binary + buffer.force_encoding(Encoding::BINARY) + when :utf8, nil + buffer.force_encoding(Encoding::UTF_8) + end + rescue ArgumentError => e + write_response 500, "Bad encoding: #{e}" + @state.clear + next + end + yield Envelope.new(@state[:sender], @state[:recipients], buffer) + @state.clear + write_response 250, 'Message sent' + else + write_response 250, 'Data received' + end + when 'RSET' + if line && !line.empty? + write_response 501, 'Unexpected parameters or arguments' + next + end + @state.clear + write_response 250, 'OK' + when 'NOOP' + if line && !line.empty? + write_response 501, 'Unexpected parameters or arguments' + next + end + write_response 250, 'OK' + when 'EXPN' + write_response 502, 'Command not implemented' + when 'VRFY' + write_response 502, 'Command not implemented' + when 'HELP' + write_response 502, 'Command not implemented' + when 'STARTTLS' + write_response 502, 'Command not implemented' + when 'QUIT' + if line && !line.empty? + write_response 501, 'Unexpected parameters or arguments' + next + end + write_response 221, 'Bye!' + close unless closed? + return + else + write_response 500, 'Syntax error, command unrecognized' + end + + task.yield + end + + close unless closed? + end + end + end end - true - end + require 'async' + require 'async/task' + require 'async/io/stream' + + class Server + def initialize(endpoint, protocol = endpoint.protocol, &block) + @endpoint = endpoint + @protocol = protocol + + define_singleton_method(:call, block) if block + end + + def accept(peer, address, task: Async::Task.current) + Async.logger.debug(self) { "Incoming connnection from #{address.inspect}" } - def receive_message - MailCatcher::Mail.add_message current_message - MailCatcher::Mail.delete_older_messages! - puts "==> SMTP: Received message from '#{current_message[:sender]}' (#{current_message[:source].length} bytes)" - true - rescue => exception - MailCatcher.log_exception("Error receiving message", @current_message, exception) - false - ensure - @current_message = nil + stream = Async::IO::Stream.new(peer) + protocol = @protocol.server(stream, hostname: @endpoint.hostname) + + protocol.each do |envelope| + Async.logger.debug(self) { "Incoming message from #{address.inspect}: #{envelope.inspect}" } + + call(envelope) + end + + Async.logger.debug(self) { "Connection from #{address.inspect} closed cleanly" } + rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, Errno::EPROTOTYPE + # Sometimes client will disconnect without completing a result or reading the entire buffer. That means we are done. + # Errno::EPROTOTYPE is a bug with Darwin. It happens because the socket is lazily created (in Darwin). + Async.logger.debug(self) { "Connection from #{address.inspect} closed: #{$ERROR_INFO}" } + end + + def call + raise NotImplementedError, 'Supply a block to MailCatcher::SMTP::Server.new or subclass and implement #call' + end + + def run + @endpoint.accept(&method(:accept)) + end + end end end diff --git a/lib/mail_catcher/version.rb b/lib/mail_catcher/version.rb index d54122d5..1e7b3109 100644 --- a/lib/mail_catcher/version.rb +++ b/lib/mail_catcher/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MailCatcher - VERSION = "0.8.1" + VERSION = "1.0.0.beta1" end diff --git a/lib/mail_catcher/web/application.rb b/lib/mail_catcher/web/application.rb index 3a2246ff..052e5460 100644 --- a/lib/mail_catcher/web/application.rb +++ b/lib/mail_catcher/web/application.rb @@ -5,15 +5,9 @@ require "uri" require "sinatra" -require "skinny" - -require "mail_catcher/bus" +require 'async/websocket/adapters/rack' require "mail_catcher/mail" -class Sinatra::Request - include Skinny::Helpers -end - module MailCatcher module Web class Application < Sinatra::Base @@ -61,21 +55,32 @@ def asset_path(filename) end get "/messages" do - if request.websocket? - request.websocket!( - :on_start => proc do |websocket| - bus_subscription = MailCatcher::Bus.subscribe do |message| + if Async::WebSocket::Adapters::Rack.websocket?(env) + Async::WebSocket::Adapters::Rack.open(env, protocols: %w[ws]) do |connection| + Async.logger.debug(connection, "Websocket connection opened") + begin + queue = Async::Queue.new + + subscription_id = MailCatcher::Bus.subscribe { |message| queue << message } + Async.logger.debug(connection, "Websocket connection subscribed to bus: #{subscription_id}") + + queue.each do |message| begin - websocket.send_message(JSON.generate(message)) - rescue => exception - MailCatcher.log_exception("Error sending message through websocket", message, exception) + Async.logger.debug(connection, "Sending #{message}") + connection.write(message) + connection.flush + rescue IOError + Async.logger.error(connection, "Failed sending #{message}", $!) + raise end end - - websocket.on_close do |*| - MailCatcher::Bus.unsubscribe bus_subscription - end - end) + rescue + Async.logger.error(connection, "Connection error", $!) + ensure + Async.logger.debug(connection, "Unsubscribing from bus, subscription #{subscription_id}") + MailCatcher::Bus.unsubscribe(subscription_id) + end + end else content_type :json JSON.generate(Mail.messages) diff --git a/mailcatcher.gemspec b/mailcatcher.gemspec index 4a9ef6b4..9e467d7e 100644 --- a/mailcatcher.gemspec +++ b/mailcatcher.gemspec @@ -32,21 +32,23 @@ Gem::Specification.new do |s| s.required_ruby_version = ">= 2.0.0" - s.add_dependency "eventmachine", "1.0.9.1" + s.add_dependency 'async', '~> 1.25' + s.add_dependency 'async-http', '~> 0.56.3' + s.add_dependency 'async-io', '~> 1.32.1' + s.add_dependency 'async-websocket', '~> 0.19.0' + s.add_dependency 'falcon', '~> 0.39.1' s.add_dependency "mail", "~> 2.3" s.add_dependency "rack", "~> 1.5" s.add_dependency "sinatra", "~> 1.2" s.add_dependency "sqlite3", "~> 1.3" - s.add_dependency "thin", "~> 1.5.0" - s.add_dependency "skinny", "~> 0.2.3" s.add_development_dependency "capybara" s.add_development_dependency "capybara-screenshot" s.add_development_dependency "coffee-script" s.add_development_dependency "compass", "~> 1.0.3" - s.add_development_dependency "rspec" s.add_development_dependency "rake" s.add_development_dependency "rdoc" + s.add_development_dependency "rspec" s.add_development_dependency "sass" s.add_development_dependency "selenium-webdriver" s.add_development_dependency "sprockets" diff --git a/spec/smtp_protocol_spec.rb b/spec/smtp_protocol_spec.rb new file mode 100644 index 00000000..f2ea6b22 --- /dev/null +++ b/spec/smtp_protocol_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require 'async/io/protocol/line' +require 'async/io/socket' +require 'mail_catcher' +require 'net/smtp' + +message = <<~MESSAGE + From: Your Name + To: Destination Address + Subject: test message + Date: Sat, 23 Jun 2001 16:26:43 +0900 + Message-Id: + + This is a test message. +MESSAGE + +SMTP_PORT = 20_025 +HTTP_PORT = 20_080 + +RSpec.describe MailCatcher::SMTP::Protocol::SMTP::Server do + let(:pipe) { @pipe = Async::IO::Socket.pair(Socket::AF_UNIX, Socket::SOCK_STREAM) } + let(:remote) { pipe.first } + let(:code) { 220 } + subject { described_class.new(Async::IO::Stream.new(pipe.last, deferred: true)) } + let(:domain) { 'test@mail.com' } + + after(:each) { defined?(@pipe) && @pipe&.each(&:close) } + + before :all do + # Start MailCatcher + @pid = spawn 'bundle', 'exec', 'mailcatcher', '--foreground', '--smtp-port', SMTP_PORT.to_s, '--http-port', + HTTP_PORT.to_s + + # Wait for it to boot + begin + TCPSocket.new('127.0.0.1', SMTP_PORT).close + TCPSocket.new('127.0.0.1', HTTP_PORT).close + rescue Errno::ECONNREFUSED + retry + end + end + + after :all do + # Quit MailCatcher at the end + Process.kill('TERM', @pid) and Process.wait + end + + context '#write_response' do + it 'should write response' do + subject.write_response(code, 'Hello World') + subject.close + + expect(remote.read.strip).to eql("#{code} Hello World") + end + end + + context '#read_line' do + before(:each) do + remote.write("Hello World\n") + remote.close + end + + it 'should read one line' do + expect(subject.read_line).to be == 'Hello World' + end + + it 'should be binary encoding' do + expect(subject.read_line.encoding).to be == Encoding::BINARY + end + end + + context '#each' do + it 'returns 250 code when HELO command' do + Net::SMTP.start('127.0.0.1', SMTP_PORT) do |smtp| + expect(smtp.helo(domain).status.to_i).to eql(250) + end + end + + it 'returns 250 code when EHLO command' do + Net::SMTP.start('127.0.0.1', SMTP_PORT) do |smtp| + expect(smtp.ehlo(domain).status.to_i).to eql(250) + end + end + + it 'raise Net::SMTPSyntaxError error when RCPT command' do + Net::SMTP.start('127.0.0.1', SMTP_PORT) do |smtp| + expect { smtp.rcptto(domain).status.to_i }.to raise_error(Net::SMTPSyntaxError) + end + end + + it 'raise Net::SMTPUnknownError when DATA command used' do + Net::SMTP.start('127.0.0.1', SMTP_PORT) do |smtp| + expect do + smtp.data("From: john@example.com + To: betty@example.com + Subject: I found a bug + + Check vm.c:58879. + ") + end.to raise_error(Net::SMTPUnknownError) + end + end + + it 'returns 250 code when RSET command' do + Net::SMTP.start('127.0.0.1', SMTP_PORT) do |smtp| + expect(smtp.rset.status.to_i).to eql(250) + end + end + + it 'returns 250 code MAIL command' do + Net::SMTP.start('127.0.0.1', SMTP_PORT) do |smtp| + expect(smtp.send_message(message, 'your@example.com', 'to@example.com').status.to_i).to eql(250) + end + end + + it 'returns 502 code STARTTLS command' do + Net::SMTP.start('127.0.0.1', SMTP_PORT) do |smtp| + expect { smtp.starttls }.to raise_error(Net::SMTPSyntaxError) + end + end + + it 'returns 221 code QUIT command' do + smtp = Net::SMTP.start('127.0.0.1', SMTP_PORT) + expect(smtp.quit.status.to_i).to eql(221) + end + end +end diff --git a/spec/smtp_spec.rb b/spec/smtp_spec.rb new file mode 100644 index 00000000..59ebcc90 --- /dev/null +++ b/spec/smtp_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +ENV["MAILCATCHER_ENV"] ||= "test" + +require "mail_catcher" + +SMTP_PORT = 20025 + +RSpec.describe MailCatcher::SMTP do + describe MailCatcher::SMTP::URLEndpoint do + let(:scheme) { 'smtp' } + let(:path) { '/path' } + let(:ip) { '127.0.0.1' } + let(:smtp_url_1) { "#{scheme}://#{ip}:#{SMTP_PORT}" } + let(:smtp_url_2) { "#{scheme}s://#{ip}#{path}" } + let(:http_address) { Async::IO::Address.tcp(ip, SMTP_PORT) } + let(:http_endpoint) { Async::IO::AddressEndpoint.new(http_address) } + let(:url_endpoint_1) { MailCatcher::SMTP::URLEndpoint.new(URI.parse(smtp_url_1), http_endpoint) } + let(:url_endpoint_2) { MailCatcher::SMTP::URLEndpoint.new(URI.parse(smtp_url_2), http_endpoint, reuse_port: true) } + + context '#to_s' do + it 'returns string url' do + expect(url_endpoint_1.to_s).to eql(smtp_url_1) + expect(url_endpoint_2.to_s).to eql(smtp_url_2) + end + end + + context '#secure?' do + it 'returns if endpoint is secure or not' do + expect(url_endpoint_1.secure?).to be(false) + expect(url_endpoint_2.secure?).to be(true) + end + end + + context '#protocol' do + it 'returns endpoint protocol' do + expect(url_endpoint_1.protocol).to be(MailCatcher::SMTP::Protocol::SMTP) + expect(url_endpoint_2.protocol).to be(MailCatcher::SMTP::Protocol::SMTP) + end + end + + context '#default_port' do + it 'returns default_port' do + expect(url_endpoint_1.default_port).to be(25) + end + end + + context '#default_port?' do + it 'returns if endpoint used default_port' do + expect(url_endpoint_1.default_port?).to be(false) + expect(url_endpoint_2.default_port?).to be(true) + end + end + + context '#port' do + it 'returns endpoint port' do + expect(url_endpoint_1.port).to be(SMTP_PORT) + expect(url_endpoint_2.port).to be(url_endpoint_2.default_port) + end + end + + context '#hostname' do + it 'returns hostname of endpoint' do + expect(url_endpoint_1.hostname).to eql(ip) + expect(url_endpoint_2.hostname).to eql(ip) + end + end + + context '#scheme' do + it 'returns scheme of endpoint' do + expect(url_endpoint_1.scheme).to eql(scheme) + expect(url_endpoint_2.scheme).to eql("#{scheme}s") + end + end + + context '#authority' do + it 'checks authority of endpoint' do + expect(url_endpoint_1.authority).to eql("#{ip}:#{SMTP_PORT}") + expect(url_endpoint_2.authority).to eql(ip) + end + end + + context '#path' do + it 'returns path of endpoint' do + expect(url_endpoint_1.path).to eql('') + expect(url_endpoint_2.path).to eql(path) + end + end + + context '#ssl_verify_mode' do + it 'checks ssl is in verify mode for the endpoint' do + expect(url_endpoint_1.ssl_verify_mode).to be(OpenSSL::SSL::VERIFY_PEER) + expect(url_endpoint_2.ssl_verify_mode).to be(OpenSSL::SSL::VERIFY_PEER) + end + end + + context '#ssl_context' do + it 'returns ssl context of the endpoint' do + expect(url_endpoint_1.ssl_context.class).to be(OpenSSL::SSL::SSLContext) + expect(url_endpoint_2.ssl_context.class).to be(OpenSSL::SSL::SSLContext) + end + end + + context '#tcp_options' do + it 'returns tcp_options of the endpoint' do + expect(url_endpoint_1.tcp_options).to eql({ :reuse_port => false }) + expect(url_endpoint_2.tcp_options).to eql({ :reuse_port => true }) + end + end + + context '#build_endpoint' do + it 'builds smtp endpoint for the endpoint' do + expect(url_endpoint_1.build_endpoint.class).to be(Async::IO::HostEndpoint) + expect(url_endpoint_2.build_endpoint.class).to be(Async::IO::SSLEndpoint) + end + end + end +end