Skip to content

Commit

Permalink
✨ Cache server capabilities; add #capable?(name)
Browse files Browse the repository at this point in the history
Added methods:
* `#capable?(name)` - the main API for discovering capabilities
* `#capabilities` - calls `capability` when needed
* `#capabilities_cached?` - whether capabilities are cached
* `#clear_cached_capabilities` - clears the cache; is thread safe

Fixes #31
  • Loading branch information
nevans committed Jul 17, 2023
1 parent a7946ab commit 75043c8
Show file tree
Hide file tree
Showing 2 changed files with 282 additions and 49 deletions.
164 changes: 115 additions & 49 deletions lib/net/imap.rb
Original file line number Diff line number Diff line change
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 @@ -725,6 +736,9 @@ class IMAP < Protocol
# Returns the initial greeting the server, an UntaggedResponse.
attr_reader :greeting

# Implementation detail; only exposed for testing
attr_reader :cached_capabilities # :nodoc:

# Seconds to wait until a connection is opened.
# If the IMAP object cannot open a connection within this time,
# it raises a Net::OpenTimeout exception. The default value is 30 seconds.
Expand Down Expand Up @@ -803,12 +817,11 @@ 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.
# Each capability is a string. Capabilities are case-insensitive.
#
# 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**: Prefer to use #capable? or #capabilities instead, to avoid
# sending unnecessary commands and correctly invalidate cached capabilities.
#
# >>>
# <em>*Note* that Net::IMAP does not currently modify its
Expand All @@ -817,49 +830,94 @@ def disconnected?
# 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.
# Related: #capable?, #capabilities, #enable
#
# Related: #enable
def capability
synchronize do
send_command("CAPABILITY")
return @responses.delete("CAPABILITY")[-1]
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 <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.
#
# Capability requirements—other than +IMAP4rev1+—are listed in the
# documentation for each command method.
#
# 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.
#
# 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. Both 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.
#
def capability
# Cached capabilities are used without sending a new #capability command to
# the server.
#
# In general, #capable? should be preferred because it doesn't rely on the
# representation of capabilities as an array of uppercase strings.
#
# Related: #capable?, #capability
def capabilities
@cached_capabilities ||= capability.freeze
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?
!!@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")
@cached_capabilities = nil
end
end

Expand All @@ -870,8 +928,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,15 +997,15 @@ 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 invalidated 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
# not</em> use them if they are sent. Servers will generally send an
# unsolicited untagged response immeditely _after_ #starttls completes.
#
def starttls(options = {}, verify = true)
send_command("STARTTLS") do |resp|
ok_response = send_command("STARTTLS") do |resp|
if resp.kind_of?(TaggedResponse) && resp.name == "OK"
begin
# for backward compatibility
Expand All @@ -959,6 +1016,8 @@ def starttls(options = {}, verify = true)
start_tls_session(options)
end
end
clear_cached_capabilities
ok_response
end

# :call-seq:
Expand Down Expand Up @@ -1015,9 +1074,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,33 +1089,35 @@ 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
# end
#
def authenticate(mechanism, ...)
authenticator = self.class.authenticator(mechanism, ...)
send_command("AUTHENTICATE", mechanism) do |resp|
ok_response = send_command("AUTHENTICATE", mechanism) do |resp|
if resp.instance_of?(ContinuationRequest)
data = authenticator.process(resp.data.text.unpack("m")[0])
s = [data].pack("m0")
send_string_data(s)
put_string(CRLF)
end
end
clear_cached_capabilities
# TODO: use capabilities from ok_response
ok_response
end

# Sends a {LOGIN command [IMAP4rev1 §6.2.3]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.2.3]
Expand All @@ -1081,7 +1142,10 @@ def authenticate(mechanism, ...)
# ResponseCode.
#
def login(user, password)
send_command("LOGIN", user, password)
ok_response = send_command("LOGIN", user, password)
clear_cached_capabilities
# TODO: use capabilities from ok_response
ok_response
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 +1325,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 @@ -2394,6 +2457,9 @@ def record_untagged_response_code(resp)
if resp.data.instance_of?(ResponseText) &&
(code = resp.data.code)
record_response(code.name, code.data)
if code.name.casecmp?("CAPABILITY")
@cached_capabilities ||= code.data.freeze
end
end
end

Expand Down
Loading

0 comments on commit 75043c8

Please sign in to comment.