diff --git a/lib/net/imap.rb b/lib/net/imap.rb index fb28f76a9..ebd452b24 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,8 @@ module Net # == What's here? # # * {Connection control}[rdoc-ref:Net::IMAP@Connection+control+methods] + # * {Server capabilities}[rdoc-ref:Net::IMAP@Server+capabilities] + # * {Handling server responses}[rdoc-ref:Net::IMAP@Handling+server+responses] # * {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] @@ -180,7 +182,6 @@ module Net # * {...for the "selected" state}[rdoc-ref:Net::IMAP@IMAP+commands+for+the+-22Selected-22+state] # * {...for the "logout" state}[rdoc-ref:Net::IMAP@IMAP+commands+for+the+-22Logout-22+state] # * {Supported IMAP extensions}[rdoc-ref:Net::IMAP@Supported+IMAP+extensions] - # * {Handling server responses}[rdoc-ref:Net::IMAP@Handling+server+responses] # # === Connection control methods # @@ -191,6 +192,33 @@ 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. + # - #auth_capable?: Returns whether the server supports a given SASL + # mechanism for use with the #authenticate command. + # - #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. + # + # In general, #capable? should be used rather than explicitly sending a + # +CAPABILITY+ command to the server. + # + # === Handling server responses + # + # - #greeting: The server's initial untagged response, which can indicate a + # pre-authenticated connection. + # - #responses: Yields unhandled UntaggedResponse#data and non-+nil+ + # ResponseCode#data. + # - #clear_responses: Deletes unhandled data from #responses and returns it. + # - #add_response_handler: Add a block to be called inside the receiver thread + # with every server response. + # - #response_handlers: Returns the list of response handlers. + # - #remove_response_handler: Remove a previously added response handler. + # # === Core \IMAP commands # # The following commands are defined either by @@ -227,8 +255,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. # @@ -506,18 +534,6 @@ module Net # TODO... #++ # - # === Handling server responses - # - # - #greeting: The server's initial untagged response, which can indicate a - # pre-authenticated connection. - # - #responses: Yields unhandled UntaggedResponse#data and non-+nil+ - # ResponseCode#data. - # - #clear_responses: Deletes unhandled data from #responses and returns it. - # - #add_response_handler: Add a block to be called inside the receiver thread - # with every server response. - # - #response_handlers: Returns the list of response handlers. - # - #remove_response_handler: Remove a previously added response handler. - # # # == References #-- @@ -803,63 +819,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. # - # 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. + 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. # - def capability + # To ensure case-insensitive capability comparison, use #capable? instead. + # + # 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 +930,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, @@ -931,21 +990,17 @@ def logout # >>> # Any #response_handlers added before STARTTLS should be aware that the # TaggedResponse to STARTTLS is sent clear-text, _before_ TLS negotiation. - # TLS negotiation starts immediately after that response. + # TLS starts immediately _after_ that response. Any response code sent + # with the response (e.g. CAPABILITY) is insecure and cannot be trusted. # # Related: Net::IMAP.new, #login, #authenticate # # ===== Capability - # - # The server's capabilities must include +STARTTLS+. + # Clients should not call #starttls unless the server advertises the + # +STARTTLS+ capability. # # Server capabilities may change after #starttls, #login, and #authenticate. - # Cached capabilities _must_ be invalidated 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 - # not use them if they are sent. Servers will generally send an - # unsolicited untagged response immeditely _after_ #starttls completes. + # Cached #capabilities will be cleared when this method completes. # def starttls(options = {}, verify = true) send_command("STARTTLS") do |resp| @@ -956,11 +1011,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 a given SASL +mechanism+ for use 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 @@ -1010,18 +1084,17 @@ def starttls(options = {}, verify = true) # for information on these and other SASL mechanisms. # # ===== Capabilities - # - # Clients MUST NOT attempt to authenticate with a mechanism unless - # "AUTH=#{mechanism}" for that mechanism is a server capability. + # Clients should not call #authenticate with mechanisms that are included in the server #capabilities as "AUTH=#{mechanism}". # # 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 will be cleared when this method completes. + # If the TaggedResponse to #authenticate includes updated capabilities, they + # will be cached. # # ===== Example - # If the authenticators ignore unhandled keyword arguments, the same config - # can be used for multiple mechanisms: + # Use auth_capable? to discover which mechanisms are suuported by the + # server. For authenticators that ignore unhandled keyword arguments, the + # same config can be used for multiple mechanisms: # # password = nil # saved locally, so we don't ask more than once # accesstok = nil # saved locally... @@ -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 auth_capable? "OAUTHBEARER" # imap.authenticate "OAUTHBEARER", **creds # authcid, oauth2_token - # elsif capa.include? "AUTH=XOAUTH2" + # elsif auth_capable? "XOAUTH2" # imap.authenticate "XOAUTH2", **creds # authcid, oauth2_token - # elsif capa.include? "AUTH=SCRAM-SHA-256" + # elsif auth_capable? "SCRAM-SHA-256" # imap.authenticate "SCRAM-SHA-256", **creds # authcid, password - # elsif capa.include? "AUTH=PLAIN" + # elsif auth_capable? "PLAIN" # imap.authenticate "PLAIN", **creds # authcid, password - # elsif capa.include? "AUTH=DIGEST-MD5" + # elsif auth_capable? "DIGEST-MD5" # imap.authenticate "DIGEST-MD5", **creds # authcid, password - # elsif capa.include? "LOGINDISABLED" + # elsif auth_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] @@ -1072,8 +1147,8 @@ def authenticate(mechanism, ...) # Related: #authenticate, #starttls # # ==== Capabilities - # Clients MUST NOT call #login if +LOGINDISABLED+ is listed with the - # capabilities. + # An IMAP client MUST NOT call #login unless the server advertises the + # +LOGINDISABLED+ capability. # # Server capabilities may change after #starttls, #login, and #authenticate. # Cached capabilities _must_ be invalidated after this method completes. @@ -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 949710227..e11c55486 100644 --- a/test/net/imap/test_imap.rb +++ b/test/net/imap/test_imap.rb @@ -849,6 +849,249 @@ 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 cache IGNORES tagged OK response to STARTTLS" do + with_fake_server(preauth: false) do |server, imap| + server.on "STARTTLS" do |cmd| + cmd.done_ok code: "[CAPABILITY IMAP4rev1 AUTH=PLAIN fnord]" + server.state.use_tls + end + + imap.starttls(ca_file: server.config.tls[:ca_file]) + assert_equal "STARTTLS", server.commands.pop.name + refute imap.capabilities_cached? + + refute imap.capable? "fnord" + assert_equal "CAPABILITY", server.commands.pop.name + 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 + + 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],