Skip to content

Commit

Permalink
✨ Cache server capabilities; add #capable?(name)
Browse files Browse the repository at this point in the history
Updated methods:
* `#initialize` - save capabilities in `OK` or `PREAUTH` greeting
* `#capability` - always update saved capabilities
* `#starttls` - always clear capabilities after tagged OK response
* `#authenticate` - clear capabilities or update from tagged OK response
* `#login` - clear capabilities or update from tagged OK response

New methods:
* `#capable?(name)` - the primary API for discovering capabilities
* `#auth_capable?(name)` - returns whether a SASL mechanism is supported
* `#capabilities` - calls `capability` when needed
* `#capabilities_cached?` - whether capabilities are cached
* `#clear_cached_capabilities` - clears the cache

Fixes #31
  • Loading branch information
nevans committed Jul 20, 2023
1 parent b21e845 commit 383761f
Show file tree
Hide file tree
Showing 2 changed files with 386 additions and 71 deletions.
228 changes: 157 additions & 71 deletions lib/net/imap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,11 @@ module Net
# === Server capabilities and protocol extensions
#
# Net::IMAP <em>does not modify its behavior</em> 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
#
Expand Down Expand Up @@ -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]
Expand All @@ -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.
#
# <em>The capabilities cache is automatically cleared after completing
# #starttls, #login, or #authenticate.</em>
# - #capability: Sends the +CAPABILITY+ command and returns the #capabilities.
#
# === Core \IMAP commands
#
# The following commands are defined either by
Expand Down Expand Up @@ -227,8 +238,8 @@ module Net
#
# - #capability: Returns the server's capabilities as an array of strings.
#
# <em>Capabilities may change after</em> #starttls, #authenticate, or #login
# <em>and cached capabilities must be reloaded.</em>
# <em>In general, #capable? should be used rather than explicitly sending a
# +CAPABILITY+ command to the server.</em>
# - #noop: Allows the server to send unsolicited untagged #responses.
# - #logout: Tells the server to end the session. Enters the "_logout_" state.
#
Expand Down Expand Up @@ -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.
#
# >>>
# <em>*NOTE*: Net::IMAP does not </em>currently<em> 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.</em>
#
# <em>Capability requirements—other than +IMAP4rev1+—are listed in the
# documentation for each command method.</em>
#
# 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.
#
# >>>
# <em>*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.</em>
# <em>*NOTE*: Net::IMAP does not </em>currently<em> 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.</em>
#
# Capability requirements—other than +IMAP4rev1+—are listed in the
# documentation for each command method.
# <em>Capability requirements—other than +IMAP4rev1+—are listed in the
# documentation for each command method.</em>
#
# 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+,
# <tt>AUTH=PLAIN</tt>, 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+, <tt>AUTH=PLAIN</tt>,
# 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

Expand All @@ -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,
Expand Down Expand Up @@ -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 <em>must
# *not*</em> send capabilities in the #starttls response and clients <em>must
Expand All @@ -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 <tt>"AUTH=#{mechanism.to_s.upcase}"</tt>.
# 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
Expand Down Expand Up @@ -1015,9 +1088,9 @@ def starttls(options = {}, verify = true)
# <tt>"AUTH=#{mechanism}"</tt> 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
Expand All @@ -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
Expand All @@ -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]
Expand All @@ -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]
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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+, <tt>SEARCH=FUZZY</tt>, +OBJECTID+, or
# +SAVEDATE+. The following are some common search criteria:
#
Expand Down Expand Up @@ -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
#
Expand Down Expand Up @@ -1942,7 +2017,7 @@ def uid_thread(algorithm, search_keys, charset)
#
# [<tt>"UTF8=ONLY"</tt> [RFC6855[https://tools.ietf.org/html/rfc6855]]]
#
# A server that reports the <tt>UTF8=ONLY</tt> #capability _requires_ that
# A server that reports the <tt>UTF8=ONLY</tt> capability _requires_ that
# the client <tt>enable("UTF8=ACCEPT")</tt> before any mailboxes may be
# selected. For convenience, <tt>enable("UTF8=ONLY")</tt> is aliased to
# <tt>enable("UTF8=ACCEPT")</tt>.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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|
Expand Down
Loading

0 comments on commit 383761f

Please sign in to comment.