diff --git a/lib/net/imap.rb b/lib/net/imap.rb index fb28f76a..52644129 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -80,11 +80,11 @@ module Net # === Server capabilities and protocol extensions # # Net::IMAP does not modify its behavior according to server - # #capability. Users of the class must check for required capabilities before - # issuing commands. Special care should be taken to follow all #capability - # requirements for #starttls, #login, and #authenticate. + # #capabilities. Users of the class must check for required capabilities + # before issuing commands. Special care should be taken to follow all + # #capabilities requirements for #starttls, #login, and #authenticate. # - # See the #capability method for more information. + # See #capable?, #capabilities, and #capability for more information. # # == Examples of Usage # @@ -173,6 +173,7 @@ module Net # == What's here? # # * {Connection control}[rdoc-ref:Net::IMAP@Connection+control+methods] + # * {Server capabilities}[rdoc-ref:Net::IMAP@Server+capabilities] # * {Core IMAP commands}[rdoc-ref:Net::IMAP@Core+IMAP+commands] # * {...for any state}[rdoc-ref:Net::IMAP@IMAP+commands+for+any+state] # * {...for the "not authenticated" state}[rdoc-ref:Net::IMAP@IMAP+commands+for+the+-22Not+Authenticated-22+state] @@ -191,6 +192,16 @@ module Net # - #disconnect: Disconnects the connection (without sending #logout first). # - #disconnected?: True if the connection has been closed. # + # === Server capabilities + # + # - #capable?: Returns whether the server supports a given capability. + # - #capabilities: Returns the server's capabilities as a list of strings. + # - #clear_cached_capabilities: Clears cached capabilities. + # + # The capabilities cache is automatically cleared after completing + # #starttls, #login, or #authenticate. + # - #capability: Sends the +CAPABILITY+ command and returns the #capabilities. + # # === Core \IMAP commands # # The following commands are defined either by @@ -227,8 +238,8 @@ module Net # # - #capability: Returns the server's capabilities as an array of strings. # - # Capabilities may change after #starttls, #authenticate, or #login - # and cached capabilities must be reloaded. + # In general, #capable? should be used rather than explicitly sending a + # +CAPABILITY+ command to the server. # - #noop: Allows the server to send unsolicited untagged #responses. # - #logout: Tells the server to end the session. Enters the "_logout_" state. # @@ -803,63 +814,107 @@ def disconnected? end # Sends a {CAPABILITY command [IMAP4rev1 §6.1.1]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.1.1] - # and returns an array of capabilities that the server supports. Each - # capability is a string. + # and returns an array of capabilities that are supported by the server. + # The result will be stored for use by #capable? and #capabilities. + # + # In general, #capable? or #capabilities should used instead. They cache + # the capability result to avoid sending unnecessary commands. They also + # ensure cache invalidation is handled correctly. + # + # >>> + # *NOTE*: Net::IMAP does not currently modify its behaviour + # according to the capabilities of the server. It is up to the user of + # the class to ensure that a certain capability is supported by a server + # before using it. + # + # Capability requirements—other than +IMAP4rev1+—are listed in the + # documentation for each command method. + # + # Related: #capable?, #capabilities, #enable + # + def capability + synchronize do + send_command("CAPABILITY") + @capabilities = @responses.delete("CAPABILITY").last.freeze + end + end + + # Returns whether the server supports a given +capability+. When available, + # cached #capabilities are used without sending a new #capability command to + # the server. # # See the {IANA IMAP4 capabilities # registry}[http://www.iana.org/assignments/imap4-capabilities] for a list # of all standard capabilities, and their reference RFCs. # # >>> - # *Note* that Net::IMAP does not currently modify its - # behaviour according to the capabilities of the server; - # it is up to the user of the class to ensure that - # a certain capability is supported by a server before - # using it. + # *NOTE*: Net::IMAP does not currently modify its behaviour + # according to the capabilities of the server. It is up to the user of + # the class to ensure that a certain capability is supported by a server + # before using it. # - # Capability requirements—other than +IMAP4rev1+—are listed in the - # documentation for each command method. + # Capability requirements—other than +IMAP4rev1+—are listed in the + # documentation for each command method. # - # Related: #enable + # Related: #capabilities, #capability, #enable # # ===== Basic IMAP4rev1 capabilities # - # All IMAP4rev1 servers must include +IMAP4rev1+ in their capabilities list. - # All IMAP4rev1 servers must _implement_ the +STARTTLS+, - # AUTH=PLAIN, and +LOGINDISABLED+ capabilities, and clients must - # respect their presence or absence. See the capabilities requirements on - # #starttls, #login, and #authenticate. + # IMAP4rev1 servers must include +IMAP4rev1+ in their capabilities list. + # IMAP4rev1 servers must _implement_ the +STARTTLS+, AUTH=PLAIN, + # and +LOGINDISABLED+ capabilities, and clients must respect their presence + # or absence. See the capabilities requirements on #starttls, #login, and + # #authenticate. # # ===== Using IMAP4rev1 extensions # - # IMAP4rev1 servers must not activate incompatible behavior until an - # explicit client action invokes a capability, e.g. sending a command or - # command argument specific to that capability. Extensions with backward - # compatible behavior, such as response codes or mailbox attributes, may - # be sent at any time. + # IMAP4rev1 servers must not activate behavior that is incompatible with the + # base specification until an explicit client action invokes a capability, + # e.g. sending a command or command argument specific to that capability. + # Servers may send data with backward compatible behavior, such as response + # codes or mailbox attributes, at any time without client action. # # Invoking capabilities which are unknown to Net::IMAP may cause unexpected - # behavior and errors, for example ResponseParseError is raised when unknown - # response syntax is received. Invoking commands or command parameters that - # are unsupported by the server may raise NoResponseError, BadResponseError, - # or cause other unexpected behavior. + # behavior and errors. For example, ResponseParseError is raised when + # unknown response syntax is received. Invoking commands or command + # parameters that are unsupported by the server may raise NoResponseError, + # BadResponseError, or cause other unexpected behavior. + # + # Some capabilities must be explicitly activated using the #enable command. + # See #enable for more details. # # ===== Caching +CAPABILITY+ responses # - # Servers may send their capability list, unsolicited, using the - # +CAPABILITY+ response code or an untagged +CAPABILITY+ response. These - # responses can be retrieved and cached using #responses or - # #add_response_handler. + # Servers may send their capability list unsolicited, using the +CAPABILITY+ + # response code or an untagged +CAPABILITY+ response. Cached capabilities + # are discarded after #starttls, #login, or #authenticate. Caching and + # cache invalidation are handled internally by Net::IMAP. + # + def capable?(capability) capabilities.include? capability.to_s.upcase end + + # Returns the server capabilities. When available, cached capabilities are + # used without sending a new #capability command to the server. # - # But cached capabilities _must_ be discarded after #starttls, #login, or - # #authenticate. The OK TaggedResponse to #login and #authenticate may - # include +CAPABILITY+ response code data, but the TaggedResponse for - # #starttls is sent clear-text and cannot be trusted. + # To ensure case-insensitive capability comparison, use #capable? instead. # - def capability + # Related: #capable?, #capability + def capabilities + @capabilities || capability + end + + # Returns whether capabilities have been cached. When true, #capable? and + # #capabilities don't require sending a #capability command to the server. + def capabilities_cached? + !!@capabilities + end + + # Clears capabilities that are currently cached by the Net::IMAP client. + # This forces a #capability command to be sent the next time that #capable? + # or #capabilities? are called. + def clear_cached_capabilities synchronize do - send_command("CAPABILITY") - return @responses.delete("CAPABILITY")[-1] + clear_responses("CAPABILITY") + @capabilities = nil end end @@ -870,8 +925,7 @@ def capability # Note that the user should first check if the server supports the ID # capability. For example: # - # capabilities = imap.capability - # if capabilities.include?("ID") + # if capable?(:ID) # id = imap.id( # name: "my IMAP client (ruby)", # version: MyIMAP::VERSION, @@ -940,7 +994,7 @@ def logout # The server's capabilities must include +STARTTLS+. # # Server capabilities may change after #starttls, #login, and #authenticate. - # Cached capabilities _must_ be invalidated after this method completes. + # Cached #capabilities are cleared after this method completes. # # The TaggedResponse to #starttls is sent clear-text, so the server must # *not* send capabilities in the #starttls response and clients must @@ -956,11 +1010,30 @@ def starttls(options = {}, verify = true) options = create_ssl_params(certs, verify) rescue NoMethodError end + clear_cached_capabilities + clear_responses start_tls_session(options) end end end + # Returns whether the server supports authenticating via a given SASL + # +mechanism+ with the #authenticate command. The +mechanism+ is supported + # when #capabilities includes "AUTH=#{mechanism.to_s.upcase}". + # When available, cached capabilities are used without sending a new + # #capability command to the server. + # + # Per {[IMAP4rev1 §6.2.2]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.2.2], + # + # imap.capable? "AUTH=PLAIN" # => true + # imap.auth_capable? "PLAIN" # => true + # imap.auth_capable? "blurdybloop" # => false + # + # Related: #authenticate, #capable?, #capabilities + def auth_capable?(mechanism) + capable? "AUTH=#{mechanism}" + end + # :call-seq: # authenticate(mechanism, ...) -> ok_resp # authenticate(mech, *creds, **props) {|prop, auth| val } -> ok_resp @@ -1015,9 +1088,9 @@ def starttls(options = {}, verify = true) # "AUTH=#{mechanism}" for that mechanism is a server capability. # # Server capabilities may change after #starttls, #login, and #authenticate. - # Cached capabilities _must_ be invalidated after this method completes. - # The TaggedResponse to #authenticate may include updated capabilities in - # its ResponseCode. + # Cached capabilities are invalidated after this method completes. The + # TaggedResponse to #authenticate may include updated capabilities in its + # ResponseCode. # # ===== Example # If the authenticators ignore unhandled keyword arguments, the same config @@ -1030,18 +1103,17 @@ def starttls(options = {}, verify = true) # password: proc { password ||= ui.prompt_for_password }, # oauth2_token: proc { accesstok ||= kms.fresh_access_token }, # } - # capa = imap.capability - # if capa.include? "AUTH=OAUTHBEARER" + # if capable? "AUTH=OAUTHBEARER" # imap.authenticate "OAUTHBEARER", **creds # authcid, oauth2_token - # elsif capa.include? "AUTH=XOAUTH2" + # elsif capable? "AUTH=XOAUTH2" # imap.authenticate "XOAUTH2", **creds # authcid, oauth2_token - # elsif capa.include? "AUTH=SCRAM-SHA-256" + # elsif capable? "AUTH=SCRAM-SHA-256" # imap.authenticate "SCRAM-SHA-256", **creds # authcid, password - # elsif capa.include? "AUTH=PLAIN" + # elsif capable? "AUTH=PLAIN" # imap.authenticate "PLAIN", **creds # authcid, password - # elsif capa.include? "AUTH=DIGEST-MD5" + # elsif capable? "AUTH=DIGEST-MD5" # imap.authenticate "DIGEST-MD5", **creds # authcid, password - # elsif capa.include? "LOGINDISABLED" + # elsif capable? "LOGINDISABLED" # raise "the server has disabled login" # else # imap.login username, password @@ -1057,6 +1129,9 @@ def authenticate(mechanism, ...) put_string(CRLF) end end + .tap { @capabilities = capabilites_from_resp_code _1 } + # NOTE: If any Net::IMAP::SASL mechanism ever supports security layer + # negotiation, capabilities sent during the "OK" response MUST be ignored. end # Sends a {LOGIN command [IMAP4rev1 §6.2.3]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.2.3] @@ -1082,6 +1157,7 @@ def authenticate(mechanism, ...) # def login(user, password) send_command("LOGIN", user, password) + .tap { @capabilities = capabilites_from_resp_code _1 } end # Sends a {SELECT command [IMAP4rev1 §6.3.1]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.3.1] @@ -1261,8 +1337,7 @@ def list(refname, mailbox) # # ===== For example: # - # capabilities = imap.capability - # if capabilities.include?("NAMESPACE") + # if capable?("NAMESPACE") # namespaces = imap.namespace # if namespace = namespaces.personal.first # prefix = namespace.prefix # e.g. "" or "INBOX." @@ -1599,7 +1674,7 @@ def uid_expunge(uid_set) # or [{IMAP4rev2 §6.4.4}[https://www.rfc-editor.org/rfc/rfc9051.html#section-6.4.4]], # in addition to documentation for # any [CAPABILITIES[https://www.iana.org/assignments/imap-capabilities/imap-capabilities.xhtml]] - # reported by #capability which may define additional search filters, e.g: + # reported by #capabilities which may define additional search filters, e.g: # +CONDSTORE+, +WITHIN+, +FILTERS+, SEARCH=FUZZY, +OBJECTID+, or # +SAVEDATE+. The following are some common search criteria: # @@ -1903,7 +1978,7 @@ def uid_thread(algorithm, search_keys, charset) # The +ENABLE+ command is only valid in the _authenticated_ state, before # any mailbox is selected. # - # Related: #capability + # Related: #capable?, #capabilities, #capability # # ===== Capabilities # @@ -1942,7 +2017,7 @@ def uid_thread(algorithm, search_keys, charset) # # ["UTF8=ONLY" [RFC6855[https://tools.ietf.org/html/rfc6855]]] # - # A server that reports the UTF8=ONLY #capability _requires_ that + # A server that reports the UTF8=ONLY capability _requires_ that # the client enable("UTF8=ACCEPT") before any mailboxes may be # selected. For convenience, enable("UTF8=ONLY") is aliased to # enable("UTF8=ACCEPT"). @@ -2247,7 +2322,8 @@ def initialize(host, port_or_options = {}, if @greeting.nil? raise Error, "connection closed" end - record_untagged_response_code(@greeting) + record_untagged_response_code @greeting + @capabilities = capabilites_from_resp_code @greeting if @greeting.name == "BYE" raise ByeResponseError, @greeting end @@ -2311,8 +2387,7 @@ def receive_responses @continuation_request_arrival.signal end when UntaggedResponse - record_response(resp.name, resp.data) - record_untagged_response_code(resp) + record_untagged_response(resp) if resp.name == "BYE" && @logout_command_tag.nil? @sock.close @exception = ByeResponseError.new(resp) @@ -2390,20 +2465,31 @@ def get_response return @parser.parse(buff) end + ############################# + # built-in response handlers + + # store name => [..., data] + def record_untagged_response(resp) + @responses[resp.name] << resp.data + record_untagged_response_code resp + end + + # store code.name => [..., code.data] def record_untagged_response_code(resp) - if resp.data.instance_of?(ResponseText) && - (code = resp.data.code) - record_response(code.name, code.data) - end + return unless resp.data.is_a?(ResponseText) + return unless (code = resp.data.code) + @responses[code.name] << code.data end - def record_response(name, data) - unless @responses.has_key?(name) - @responses[name] = [] - end - @responses[name].push(data) + def capabilites_from_resp_code(resp) + return unless %w[PREAUTH OK].any? { _1.casecmp? resp.name } + return unless (code = resp.data.code) + return unless code.name.casecmp?("CAPABILITY") + code.data.freeze end + ############################# + def send_command(cmd, *args, &block) synchronize do args.each do |i| diff --git a/test/net/imap/test_imap.rb b/test/net/imap/test_imap.rb index 94971022..3dada0ad 100644 --- a/test/net/imap/test_imap.rb +++ b/test/net/imap/test_imap.rb @@ -849,6 +849,235 @@ def test_uidplus_uidnotsticky end end + test "#capabilities returns cached CAPABILITY data" do + with_fake_server do |server, imap| + imap.clear_cached_capabilities + assert_empty server.commands + 10.times do + assert_equal(%w[IMAP4REV1 NAMESPACE MOVE IDLE UTF8=ACCEPT], + imap.capabilities) + end + assert_equal "CAPABILITY", server.commands.pop.name + assert_empty server.commands + end + end + + test "#capable?(name) checks cached CAPABILITY data for name" do + with_fake_server do |server, imap| + imap.clear_cached_capabilities + assert_empty server.commands + 10.times do + assert imap.capable? "IMAP4rev1" + assert imap.capable? :NAMESPACE + assert imap.capable? "idle" + refute imap.capable? "LOGINDISABLED" + refute imap.capable? "auth=plain" + end + assert_equal "CAPABILITY", server.commands.pop.name + assert_empty server.commands + end + end + + test "#auth_capable?(name) checks cached capabilities for AUTH=name" do + with_fake_server( + preauth: false, cleartext_auth: true, + sasl_mechanisms: %i[PLAIN SCRAM-SHA-1 SCRAM-SHA-256 XOAUTH2 OAUTHBEARER], + ) do |server, imap| + imap.clear_cached_capabilities + assert_empty server.commands + 10.times do + assert imap.auth_capable? :PLAIN + assert imap.auth_capable? "scram-sha-1" + assert imap.auth_capable? "OAuthBearer" + assert imap.auth_capable? :XOAuth2 + refute imap.auth_capable? "EXTERNAL" + refute imap.auth_capable? :LOGIN + refute imap.auth_capable? "anonymous" + end + assert_equal "CAPABILITY", server.commands.pop.name + assert_empty server.commands + end + end + + test "#clear_cached_capabilities clears cached capabilities" do + with_fake_server do |server, imap| + assert imap.capable?(:IMAP4rev1) + assert imap.capabilities_cached? + assert_empty server.commands + imap.clear_cached_capabilities + refute imap.capabilities_cached? + assert imap.capable?(:IMAP4rev1) + assert_equal "CAPABILITY", server.commands.pop.name + assert imap.capabilities_cached? + end + end + + test "#capabilty caches its result" do + with_fake_server(greeting_capabilities: false) do |server, imap| + imap.capability + assert imap.capabilities_cached? + assert_equal "CAPABILITY", server.commands.pop.name + assert_empty server.commands + end + end + + test "#capabilities caches greeting capabilities (cleartext)" do + with_fake_server( + preauth: false, cleartext_login: false, cleartext_auth: false, + ) do |server, imap| + assert imap.capabilities_cached? + assert_equal %w[IMAP4REV1 STARTTLS LOGINDISABLED], imap.capabilities + refute imap.auth_capable? "plain" + assert_empty server.commands + end + end + + test "#capabilities caches greeting capabilities (PREAUTH)" do + with_fake_server(preauth: true) do |server, imap| + assert imap.capabilities_cached? + assert_equal %w[IMAP4REV1 NAMESPACE MOVE IDLE UTF8=ACCEPT], + imap.capabilities + assert_empty server.commands + end + end + + if defined?(OpenSSL::SSL::SSLError) + test "#capabilities caches greeting capabilities (implicit TLS)" do + with_fake_server(preauth: false, implicit_tls: true) do |server, imap| + assert imap.capabilities_cached? + assert_equal %w[IMAP4REV1 AUTH=PLAIN], imap.capabilities + assert imap.auth_capable? "plain" + assert_empty server.commands + end + end + + test "#capabilities cache is cleared after #starttls" do + with_fake_server(preauth: false, cleartext_auth: false) do |server, imap| + assert imap.capabilities_cached? + assert imap.capable? :IMAP4rev1 + refute imap.auth_capable? "plain" + + imap.starttls(ca_file: server.config.tls[:ca_file]) + assert_equal "STARTTLS", server.commands.pop.name + refute imap.capabilities_cached? + + assert imap.capable? :IMAP4rev1 + assert imap.auth_capable? "plain" + assert_equal "CAPABILITY", server.commands.pop.name + assert imap.capabilities_cached? + assert_empty server.commands + end + end + end + + test "#capabilities cache is cleared after #login" do + with_fake_server(preauth: false, cleartext_login: true) do |server, imap| + assert imap.capable? :IMAP4rev1 + assert imap.capabilities_cached? + + imap.login("test_user", "test-password") + assert_equal "LOGIN", server.commands.pop.name + refute imap.capabilities_cached? + + assert imap.capable? :IMAP4rev1 + assert_equal "CAPABILITY", server.commands.pop.name + assert imap.capabilities_cached? + assert_empty server.commands + end + end + + test "#capabilities cache is cleared after #authenticate" do + with_fake_server(preauth: false, cleartext_auth: true) do |server, imap| + assert imap.capable?("AUTH=PLAIN") + + imap.authenticate("PLAIN", "test_user", "test-password") + assert_equal "AUTHENTICATE", server.commands.pop.name + refute imap.capabilities_cached? + + assert imap.capable? :IMAP4rev1 + assert_equal "CAPABILITY", server.commands.pop.name + assert_empty server.commands + end + end + + test "#capabilities caches tagged OK response to LOGIN" do + with_fake_server(preauth: false, cleartext_login: true) do |server, imap| + server.on "LOGIN" do |cmd| + server.state.authenticate server.config.user + cmd.done_ok code: "[CAPABILITY IMAP4rev1 IMAP4rev2 MOVE NAMESPACE" \ + " ENABLE IDLE UIDPLUS UNSELECT UTF8=ACCEPT]" + end + + imap.login("test_user", "test-password") + assert_equal "LOGIN", server.commands.pop.name + assert imap.capabilities_cached? + + assert imap.capable? :IMAP4rev1 + assert imap.capable? :IMAP4rev2 + assert imap.capable? "UIDPLUS" + assert_empty server.commands + end + end + + test "#capabilities caches tagged OK response to AUTHENTICATE" do + with_fake_server(preauth: false, cleartext_login: true) do |server, imap| + server.on "AUTHENTICATE" do |cmd| + cmd.request_continuation "" + server.state.authenticate server.config.user + cmd.done_ok code: "[CAPABILITY IMAP4rev1 IMAP4rev2 MOVE NAMESPACE" \ + " ENABLE IDLE UIDPLUS UNSELECT UTF8=ACCEPT]" + end + + imap.authenticate("PLAIN", "test_user", "test-password") + assert_equal "AUTHENTICATE", server.commands.pop.name + assert imap.capabilities_cached? + + assert imap.capable? :IMAP4rev1 + assert imap.capable? :IMAP4rev2 + assert imap.capable? "UIDPLUS" + assert_empty server.commands + end + end + + # TODO: capabilities cache IGNORES tagged OK response to STARTTLS + + test "#capabilities cache is NOT cleared after #login fails" do + with_fake_server(preauth: false, cleartext_auth: true) do |server, imap| + original_capabilities = imap.capabilities + begin + imap.login("wrong_user", "wrong-password") + rescue Net::IMAP::NoResponseError + end + assert_equal "LOGIN", server.commands.pop.name + assert_equal original_capabilities, imap.capabilities + assert_empty server.commands + end + end + + test "#capabilities cache is NOT cleared after #authenticate fails" do + with_fake_server(preauth: false, cleartext_auth: true) do |server, imap| + original_capabilities = imap.capabilities + begin + imap.authenticate("PLAIN", "wrong_user", "wrong-password") + rescue Net::IMAP::NoResponseError + end + assert_equal "AUTHENTICATE", server.commands.pop.name + assert_equal original_capabilities, imap.capabilities + assert_empty server.commands + end + end + + # NOTE: other recorded responses are cleared after #select + test "#capabilities cache is retained after selecting a mailbox" do + with_fake_server do |server, imap| + original_capabilities = imap.capabilities + imap.select "inbox" + assert_equal "SELECT", server.commands.pop.name + assert_equal original_capabilities, imap.capabilities + assert_empty server.commands + end + end + def test_enable with_fake_server( with_extensions: %i[ENABLE CONDSTORE UTF8=ACCEPT],