diff --git a/lib/net/imap.rb b/lib/net/imap.rb index 754d3282..bd763c01 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -725,6 +725,107 @@ class << self alias default_imap_port default_port alias default_imaps_port default_tls_port alias default_ssl_port default_tls_port + + # The default value for the +tls+ option of ::new, when +port+ is + # unspecified or non-standard. + # + # *Note*: A future release of Net::IMAP will set the default to +true+, as + # per RFC7525[https://tools.ietf.org/html/rfc7525], + # RFC7817[https://tools.ietf.org/html/rfc7817], and + # RFC8314[https://tools.ietf.org/html/rfc8314]. + # + # Set to +true+ for the secure default without warnings. Set to + # +false+ to globally silence warnings and use insecure defaults. + attr_accessor :default_tls + alias default_ssl default_tls + end + + # Creates a new Net::IMAP object and connects it to the specified + # +host+. + # + # Accepts the following options: + # + # [port] + # Port number (default value is 143 for imap, or 993 for imaps) + # [tls] + # When +true+, the connection will use TLS with the default params set by + # {OpenSSL::SSL::SSLContext#set_params}[https://docs.ruby-lang.org/en/master/OpenSSL/SSL/SSLContext.html#method-i-set_params]. + # Assign a hash to override TLS params—the keys are assignment methods on + # SSLContext[https://docs.ruby-lang.org/en/master/OpenSSL/SSL/SSLContext.html]. + # + # When port: 993, +tls+ defaults to +true+. + # When port: 143, +tls+ defaults to +false+. + # When port is unspecified or non-standard, +tls+ defaults to + # ::default_tls. When ::default_tls is also +nil+, a warning is printed + # and the connection does _not_ use TLS. + # + # When +nil+ or unassigned a default value is assigned: the default is + # +true+ if port: 993, +false+ if port: 143, and + # ::default_tls when +port+ is unspecified or non-standard. When + # ::default_tls is +nil+, a back + # + # [open_timeout] + # Seconds to wait until a connection is opened + # [idle_response_timeout] + # Seconds to wait until an IDLE response is received + # + # The most common errors are: + # + # Errno::ECONNREFUSED:: Connection refused by +host+ or an intervening + # firewall. + # Errno::ETIMEDOUT:: Connection timed out (possibly due to packets + # being dropped by an intervening firewall). + # Errno::ENETUNREACH:: There is no route to that network. + # SocketError:: Hostname not known or other socket error. + # Net::IMAP::ByeResponseError:: Connected to the host successfully, but + # it immediately said goodbye. + def initialize(host, + port: nil, + tls: nil, + open_timeout: 30, + idle_response_timeout: 5) + super() # (MonitorMixin) + @host = host + @tls, @port = default_tls_and_port(tls, port) + @open_timeout = Integer(open_timeout) + @idle_response_timeout = Integer(idle_response_timeout) + + # Basic Client state + @tls_verified = false + @greeting = nil + @capabilities = nil + @utf8_strings = false # TODO: use @enabled instead + @debug_output_bol = true + + # Client Protocol Reciever + @parser = ResponseParser.new + @receiver_thread = nil + @receiver_thread_terminating = false + @exception = nil + + # Client Protocol Sender + @tag_prefix = "RUBY" + @tagno = 0 + + # Response handlers + @continuation_request_arrival = new_cond + @continuation_request_exception = nil + @tagged_response_arrival = new_cond + @tagged_responses = {} + @response_handlers = [] + @responses = Hash.new {|h, k| h[k] = [] } + + # Command execution state + @logout_command_tag = nil + @continued_command_tag = nil + @idle_done_cond = nil + + # DEPRECATED + @client_thread = Thread.current + + # create the connection + @sock = nil + start_connection end def client_thread # :nodoc: @@ -800,7 +901,7 @@ def capabilities # servers will drop all AUTH= mechanisms from #capabilities after # the connection has authenticated. # - # imap = Net::IMAP.new(hostname, ssl: false) + # imap = Net::IMAP.new(hostname, tls: false) # imap.capabilities # => ["IMAP4REV1", "LOGINDISABLED"] # imap.auth_mechanisms # => [] # @@ -975,15 +1076,9 @@ def logout # Server capabilities may change after #starttls, #login, and #authenticate. # Cached #capabilities will be cleared when this method completes. # - def starttls(options = {}, verify = true) + def starttls(**options) send_command("STARTTLS") do |resp| if resp.kind_of?(TaggedResponse) && resp.name == "OK" - begin - # for backward compatibility - certs = options.to_str - options = create_ssl_params(certs, verify) - rescue NoMethodError - end clear_cached_capabilities clear_responses start_tls_session(options) @@ -2218,100 +2313,62 @@ def remove_response_handler(handler) @@debug = false - # :call-seq: - # Net::IMAP.new(host, options = {}) - # - # Creates a new Net::IMAP object and connects it to the specified - # +host+. - # - # +options+ is an option hash, each key of which is a symbol. - # - # The available options are: - # - # port:: Port number (default value is 143 for imap, or 993 for imaps) - # ssl:: If +options[:ssl]+ is true, then an attempt will be made - # to use SSL (now TLS) to connect to the server. - # If +options[:ssl]+ is a hash, it's passed to - # OpenSSL::SSL::SSLContext#set_params as parameters. - # open_timeout:: Seconds to wait until a connection is opened - # idle_response_timeout:: Seconds to wait until an IDLE response is received - # - # The most common errors are: - # - # Errno::ECONNREFUSED:: Connection refused by +host+ or an intervening - # firewall. - # Errno::ETIMEDOUT:: Connection timed out (possibly due to packets - # being dropped by an intervening firewall). - # Errno::ENETUNREACH:: There is no route to that network. - # SocketError:: Hostname not known or other socket error. - # Net::IMAP::ByeResponseError:: The connected to the host was successful, but - # it immediately said goodbye. - def initialize(host, port_or_options = {}, - usessl = false, certs = nil, verify = true) - super() - @host = host - begin - options = port_or_options.to_hash - rescue NoMethodError - # for backward compatibility - options = {} - options[:port] = port_or_options - if usessl - options[:ssl] = create_ssl_params(certs, verify) + def default_tls_and_port(tls, port) + if tls.nil? && port + tls = true if port == SSL_PORT || /\Aimaps\z/i === port + tls = false if port == PORT + elsif port.nil? && !tls.nil? + port = tls ? SSL_PORT : PORT + end + if tls.nil? && port.nil? + tls = self.class.default_tls.dup.freeze + port = tls ? SSL_PORT : PORT + if tls.nil? + warn "A future version of Net::IMAP.default_tls " \ + "will default to 'true', for secure connections by default. " \ + "Use 'Net::IMAP.new(host, tls: false)' or set " \ + "Net::IMAP.default_tls = false' to silence this warning." end end - @port = options[:port] || (options[:ssl] ? SSL_PORT : PORT) - @tag_prefix = "RUBY" - @tagno = 0 - @utf8_strings = false - @open_timeout = options[:open_timeout] || 30 - @idle_response_timeout = options[:idle_response_timeout] || 5 - @tls_verified = false - @parser = ResponseParser.new + tls &&= tls.respond_to?(:to_hash) ? tls.to_hash : {} + [tls, port] + end + + def start_connection @sock = tcp_socket(@host, @port) begin - if options[:ssl] - start_tls_session(options[:ssl]) - @usessl = true - else - @usessl = false - end - @responses = Hash.new {|h, k| h[k] = [] } - @tagged_responses = {} - @response_handlers = [] - @tagged_response_arrival = new_cond - @continued_command_tag = nil - @continuation_request_arrival = new_cond - @continuation_request_exception = nil - @idle_done_cond = nil - @logout_command_tag = nil - @debug_output_bol = true - @exception = nil - + start_tls_session(@tls) if @tls @greeting = get_response - if @greeting.nil? - raise Error, "connection closed" - end - record_untagged_response_code @greeting - @capabilities = capabilities_from_resp_code @greeting - if @greeting.name == "BYE" - raise ByeResponseError, @greeting - end - - @client_thread = Thread.current - @receiver_thread = Thread.start { - begin - receive_responses - rescue Exception - end - } - @receiver_thread_terminating = false + handle_server_greeting + @receiver_thread = start_receiver_thread rescue Exception @sock.close raise end end + def handle_server_greeting + if @greeting.nil? + raise Error, "connection closed" + end + record_untagged_response_code(@greeting) + @capabilities = capabilities_from_resp_code @greeting + if @greeting.name == "BYE" + raise ByeResponseError, @greeting + end + end + + def start_receiver_thread + Thread.start do + receive_responses + rescue Exception + # don't exit the thread with an exception + end + rescue Exception + @sock.close + raise + end + def tcp_socket(host, port) s = Socket.tcp(host, port, :connect_timeout => @open_timeout) s.setsockopt(:SOL_SOCKET, :SO_KEEPALIVE, true) @@ -2598,34 +2655,12 @@ def normalize_searching_criteria(keys) end end - def create_ssl_params(certs = nil, verify = true) - params = {} - if certs - if File.file?(certs) - params[:ca_file] = certs - elsif File.directory?(certs) - params[:ca_path] = certs - end - end - if verify - params[:verify_mode] = VERIFY_PEER - else - params[:verify_mode] = VERIFY_NONE - end - return params - end - def start_tls_session(params = {}) unless defined?(OpenSSL::SSL) - raise "SSL extension not installed" + raise "OpenSSL extension not installed" end if @sock.kind_of?(OpenSSL::SSL::SSLSocket) - raise RuntimeError, "already using SSL" - end - begin - params = params.to_hash - rescue NoMethodError - params = {} + raise RuntimeError, "already using TLS" end context = SSLContext.new context.set_params(params) @@ -2662,3 +2697,6 @@ def self.saslprep(string, **opts) require_relative "imap/response_data" require_relative "imap/response_parser" require_relative "imap/authenticators" + +require_relative "imap/deprecated_client_options" +Net::IMAP.prepend Net::IMAP::DeprecatedClientOptions diff --git a/lib/net/imap/deprecated_client_options.rb b/lib/net/imap/deprecated_client_options.rb new file mode 100644 index 00000000..12dbacc6 --- /dev/null +++ b/lib/net/imap/deprecated_client_options.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +module Net + class IMAP < Protocol + + # This module handles deprecated arguments to various methods. + module DeprecatedClientOptions + UNDEF = Module.new.freeze + private_constant :UNDEF + + # :call-seq: + # Net::IMAP.new(host, ssl: nil, **options) + # Net::IMAP.new(host, port) + # Net::IMAP.new(host, options = {}) + # Net::IMAP.new(host, port = nil, usessl = false, certs = nil, verify = true) + # + # Translates Net::IMAP.new arguments for backward compatibility. + # + # ==== Obsolete arguments + # + # If +ssl+ is given, it is silently converted to the +tls+ keyword + # argument. Combining both +ssl+ and +tls+ raises an ArgumentError. Both + # of the following behave identically: + # + # Net::IMAP.new("imap.example.com", port: 993, ssl: {ca_path: "path/to/certs"}) + # Net::IMAP.new("imap.example.com", port: 993, tls: {ca_path: "path/to/certs"}) + # + # If a second positional argument is given and it is an integer, it is + # silently converted to the +port+ keyword argument. + # + # Net::IMAP.new("imap.example.com", 993) + # Net::IMAP.new("imap.example.com", port: 993) + # + # Obsolete arguments may print deprecation warnings in a future release. + # + # ==== Deprecated arguments + # + # Using any deprecated arguments will print a warning. Deprecated + # arguments will be removed in a future release. + # + # If a second positional argument is given and it is a hash, it is + # converted to keyword arguments. + # + # # DEPRECATED: + # Net::IMAP.new("imap.example.com", options_hash) # => prints a warning + # # Not deprecated: + # Net::IMAP.new("imap.example.com", **options_hash) + # + # If a second positional argument is given and it is not an integer or + # hash, it is converted to the +port+ keyword argument—with a warning. + # + # # DEPRECATED: + # Net::IMAP.new("imap.example.com", "imap") # => prints a warning + # # Not deprecated: + # Net::IMAP.new("imap.example.com", port: "imap") + # + # If +usessl+ is false, +certs+, and +verify+ are ignored. When it true, + # all three arguments are converted to the +tls+ keyword argument. + # + # # DEPRECATED: usessl = true + # Net::IMAP.new("imap.example.com", 993, true) + # # Not deprecated: keywords port and tls + # Net::IMAP.new("imap.example.com", port: 993, tls: true) + # + # # DEPRECATED: certs = path to a directory + # Net::IMAP.new("imap.example.com", nil, true, "/path/to/certs") # => prints a warning + # # Not deprecated: OpenSSL::SSL::SSLContext param: ca_path + # Net::IMAP.new("imap.example.com", tls: {ca_path: "/path/to/certs", verify_mode: VERIFY_PEER}) + # + # # DEPRECATED: certs = path to a file + # Net::IMAP.new("imap.example.com", nil, true, "/path/to/cert.pem") # => prints a warning + # # Not deprecated: OpenSSL::SSL::SSLContext param: ca_file + # Net::IMAP.new("imap.example.com", tls: {ca_file: "/path/to/cert.pem", verify_mode: VERIFY_PEER}) + # + # # DEPRECATED: verify = false + # Net::IMAP.new("imap.example.com", nil, true, nil, false) # => prints a warning + # # Not deprecated: +tls+ OpenSSL::SSL::SSLContext param: verify_mode + # Net::IMAP.new("imap.example.com", tls: {verify_mode: VERIFY_NONE}) + # + def initialize(host, + port_or_options = UNDEF, + usessl = UNDEF, + certs = UNDEF, + verify = UNDEF, + **options) + if port_or_options == UNDEF + translate_ssl_to_tls(options) + super(host, **options) + elsif options.any? + raise ArgumentError, "send options as keyword arguments only" + + elsif usessl == UNDEF + if (options = Hash.try_convert(port_or_options)&.dup) + translate_ssl_to_tls(options) + super(host, **options) + else + super(host, port: port_or_options) + end + elsif port_or_options.respond_to?(:to_hash) + raise ArgumentError, "do not send extra arguments with options hash" + + else + warn "DEPRECATED: Net::IMAP.new(host, port, usessl, certs, verify). " \ + "Use Net::IMAP.new(host, **options) instead." + super(host, + port: port_or_options, + tls: usessl && obsolete_create_ssl_params(certs, verify)) + end + end + + # :call-seq: + # starttls(options = {}) + # starttls(certs, verify = true) + # + # For backward compatibility. A future release will only accept + # OpenSSL::SSL::SSLContext.set_params options. + def starttls(options_or_certs = UNDEF, verify = UNDEF, **options) + if options_or_certs == UNDEF + super(**options) + elsif options.any? + raise ArgumentError, "send options as keyword arguments only" + + elsif (options = Hash.try_convert(options_or_certs)) + if verify != UNDEF + raise ArgumentError, "do not send extra arguments with options hash" + end + super(**options) + + elsif (certs = String.try_convert(options_or_certs)) + warn "DEPRECATED: starttls(certs, verify). " \ + "Use starttls(**ssl_context_params) instead." + super(**obsolete_create_ssl_params(certs, verify)) + + else + raise ArgumentError, "send options as keyword arguments" + end + end + + private + + def translate_ssl_to_tls(options) + return unless options.key?(:ssl) + if options.key?(:tls) + raise ArgumentError, "conflicting :ssl and :tls keyword arguments" + end + options.merge!(tls: options.delete(:ssl)) + end + + def obsolete_create_ssl_params(certs, verify) + certs = nil if certs == UNDEF + verify = true if verify == UNDEF + params = {} + if certs + if File.file?(certs) + params[:ca_file] = certs + elsif File.directory?(certs) + params[:ca_path] = certs + end + end + params[:verify_mode] = verify ? VERIFY_PEER : VERIFY_NONE + params + end + + end + end +end