Skip to content

Commit 518ceb3

Browse files
committed
✨ Track IMAP connection state
Tracking connection state can have many uses. But in this version, we're only tracking the session state and exposing a `#connection_state` attribute reader. For forward-compatibility, the only documented methods on the connection state objects are (currently) `#name` and `#to_sym`, which will return one of `not_authenticated`, `authenticated`, `selected`, or `logout`. From RFC9051: ``` +----------------------+ |connection established| +----------------------+ || \/ +--------------------------------------+ | server greeting | +--------------------------------------+ || (1) || (2) || (3) \/ || || +-----------------+ || || |Not Authenticated| || || +-----------------+ || || || (7) || (4) || || || \/ \/ || || +----------------+ || || | Authenticated |<=++ || || +----------------+ || || || || (7) || (5) || (6) || || || \/ || || || || +--------+ || || || || |Selected|==++ || || || +--------+ || || || || (7) || \/ \/ \/ \/ +--------------------------------------+ | Logout | +--------------------------------------+ || \/ +-------------------------------+ |both sides close the connection| +-------------------------------+ ``` > Legend for the above diagram: > > (1) connection without pre-authentication (OK greeting) > (2) pre-authenticated connection (PREAUTH greeting) > (3) rejected connection (BYE greeting) > (4) successful LOGIN or AUTHENTICATE command > (5) successful SELECT or EXAMINE command > (6) CLOSE or UNSELECT command, unsolicited CLOSED response code, or > failed SELECT or EXAMINE command > (7) LOGOUT command, server shutdown, or connection closed Before the server greeting, the state is `not_authenticated`. After the connection closes, the state remains `logout`.
1 parent 487f110 commit 518ceb3

File tree

3 files changed

+386
-7
lines changed

3 files changed

+386
-7
lines changed

lib/net/imap.rb

Lines changed: 112 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ module Net
5353
# states: <tt>not authenticated</tt>, +authenticated+, +selected+, and
5454
# +logout+. Most commands are valid only in certain states.
5555
#
56+
# See #connection_state.
57+
#
5658
# === Sequence numbers and UIDs
5759
#
5860
# Messages have two sorts of identifiers: message sequence
@@ -758,9 +760,10 @@ class IMAP < Protocol
758760
"UTF8=ONLY" => "UTF8=ACCEPT",
759761
}.freeze
760762

761-
autoload :SASL, File.expand_path("imap/sasl", __dir__)
762-
autoload :SASLAdapter, File.expand_path("imap/sasl_adapter", __dir__)
763-
autoload :StringPrep, File.expand_path("imap/stringprep", __dir__)
763+
autoload :ConnectionState, File.expand_path("imap/connection_state", __dir__)
764+
autoload :SASL, File.expand_path("imap/sasl", __dir__)
765+
autoload :SASLAdapter, File.expand_path("imap/sasl_adapter", __dir__)
766+
autoload :StringPrep, File.expand_path("imap/stringprep", __dir__)
764767

765768
include MonitorMixin
766769
if defined?(OpenSSL::SSL)
@@ -833,6 +836,67 @@ def idle_response_timeout; config.idle_response_timeout end
833836
# Returns +false+ for a plaintext connection.
834837
attr_reader :ssl_ctx_params
835838

839+
# Returns the current connection state.
840+
#
841+
# Once an IMAP connection is established, the connection is in one of four
842+
# states: +not_authenticated+, +authenticated+, +selected+, and +logout+.
843+
# Most commands are valid only in certain states.
844+
#
845+
# The connection state object responds to +to_sym+ and +name+ with the name
846+
# of the current connection state, as a Symbol or String. Future versions
847+
# of +net-imap+ may store additional information on the state object.
848+
#
849+
# From {RFC9051}[https://www.rfc-editor.org/rfc/rfc9051#section-3]:
850+
# +----------------------+
851+
# |connection established|
852+
# +----------------------+
853+
# ||
854+
# \/
855+
# +--------------------------------------+
856+
# | server greeting |
857+
# +--------------------------------------+
858+
# || (1) || (2) || (3)
859+
# \/ || ||
860+
# +-----------------+ || ||
861+
# |Not Authenticated| || ||
862+
# +-----------------+ || ||
863+
# || (7) || (4) || ||
864+
# || \/ \/ ||
865+
# || +----------------+ ||
866+
# || | Authenticated |<=++ ||
867+
# || +----------------+ || ||
868+
# || || (7) || (5) || (6) ||
869+
# || || \/ || ||
870+
# || || +--------+ || ||
871+
# || || |Selected|==++ ||
872+
# || || +--------+ ||
873+
# || || || (7) ||
874+
# \/ \/ \/ \/
875+
# +--------------------------------------+
876+
# | Logout |
877+
# +--------------------------------------+
878+
# ||
879+
# \/
880+
# +-------------------------------+
881+
# |both sides close the connection|
882+
# +-------------------------------+
883+
#
884+
# >>>
885+
# Legend for the above diagram:
886+
#
887+
# 1. connection without pre-authentication (+OK+ #greeting)
888+
# 2. pre-authenticated connection (+PREAUTH+ #greeting)
889+
# 3. rejected connection (+BYE+ #greeting)
890+
# 4. successful #login or #authenticate command
891+
# 5. successful #select or #examine command
892+
# 6. #close or #unselect command, unsolicited +CLOSED+ response code, or
893+
# failed #select or #examine command
894+
# 7. #logout command, server shutdown, or connection closed
895+
#
896+
# Before the server greeting, the state is +not_authenticated+.
897+
# After the connection closes, the state remains +logout+.
898+
attr_reader :connection_state
899+
836900
# Creates a new Net::IMAP object and connects it to the specified
837901
# +host+.
838902
#
@@ -952,6 +1016,8 @@ def initialize(host, port: nil, ssl: nil,
9521016
@exception = nil
9531017
@greeting = nil
9541018
@capabilities = nil
1019+
@tls_verified = false
1020+
@connection_state = ConnectionState::NotAuthenticated.new
9551021

9561022
# Client Protocol Receiver
9571023
@parser = ResponseParser.new(config: @config)
@@ -973,7 +1039,6 @@ def initialize(host, port: nil, ssl: nil,
9731039
@logout_command_tag = nil
9741040

9751041
# Connection
976-
@tls_verified = false
9771042
@sock = tcp_socket(@host, @port)
9781043
start_tls_session if ssl_ctx
9791044
start_imap_connection
@@ -989,6 +1054,7 @@ def tls_verified?; @tls_verified end
9891054
# Related: #logout, #logout!
9901055
def disconnect
9911056
return if disconnected?
1057+
state_logout!
9921058
begin
9931059
begin
9941060
# try to call SSL::SSLSocket#io.
@@ -1374,7 +1440,7 @@ def starttls(**options)
13741440
# capabilities, they will be cached.
13751441
def authenticate(*args, sasl_ir: config.sasl_ir, **props, &callback)
13761442
sasl_adapter.authenticate(*args, sasl_ir: sasl_ir, **props, &callback)
1377-
.tap { @capabilities = capabilities_from_resp_code _1 }
1443+
.tap do state_authenticated! _1 end
13781444
end
13791445

13801446
# Sends a {LOGIN command [IMAP4rev1 §6.2.3]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.2.3]
@@ -1408,7 +1474,7 @@ def login(user, password)
14081474
raise LoginDisabledError
14091475
end
14101476
send_command("LOGIN", user, password)
1411-
.tap { @capabilities = capabilities_from_resp_code _1 }
1477+
.tap do state_authenticated! _1 end
14121478
end
14131479

14141480
# Sends a {SELECT command [IMAP4rev1 §6.3.1]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.3.1]
@@ -1448,8 +1514,10 @@ def select(mailbox, condstore: false)
14481514
args = ["SELECT", mailbox]
14491515
args << ["CONDSTORE"] if condstore
14501516
synchronize do
1517+
state_unselected! # implicitly closes current mailbox
14511518
@responses.clear
14521519
send_command(*args)
1520+
.tap do state_selected! end
14531521
end
14541522
end
14551523

@@ -1466,8 +1534,10 @@ def examine(mailbox, condstore: false)
14661534
args = ["EXAMINE", mailbox]
14671535
args << ["CONDSTORE"] if condstore
14681536
synchronize do
1537+
state_unselected! # implicitly closes current mailbox
14691538
@responses.clear
14701539
send_command(*args)
1540+
.tap do state_selected! end
14711541
end
14721542
end
14731543

@@ -1906,6 +1976,7 @@ def check
19061976
# Related: #unselect
19071977
def close
19081978
send_command("CLOSE")
1979+
.tap do state_authenticated! end
19091980
end
19101981

19111982
# Sends an {UNSELECT command [RFC3691 §2]}[https://www.rfc-editor.org/rfc/rfc3691#section-3]
@@ -1922,6 +1993,7 @@ def close
19221993
# [RFC3691[https://www.rfc-editor.org/rfc/rfc3691]].
19231994
def unselect
19241995
send_command("UNSELECT")
1996+
.tap do state_authenticated! end
19251997
end
19261998

19271999
# call-seq:
@@ -3180,6 +3252,7 @@ def start_imap_connection
31803252
@capabilities = capabilities_from_resp_code @greeting
31813253
@receiver_thread = start_receiver_thread
31823254
rescue Exception
3255+
state_logout!
31833256
@sock.close
31843257
raise
31853258
end
@@ -3188,7 +3261,10 @@ def get_server_greeting
31883261
greeting = get_response
31893262
raise Error, "No server greeting - connection closed" unless greeting
31903263
record_untagged_response_code greeting
3191-
raise ByeResponseError, greeting if greeting.name == "BYE"
3264+
case greeting.name
3265+
when "PREAUTH" then state_authenticated!
3266+
when "BYE" then state_logout!; raise ByeResponseError, greeting
3267+
end
31923268
greeting
31933269
end
31943270

@@ -3198,6 +3274,8 @@ def start_receiver_thread
31983274
rescue Exception => ex
31993275
@receiver_thread_exception = ex
32003276
# don't exit the thread with an exception
3277+
ensure
3278+
state_logout!
32013279
end
32023280
end
32033281

@@ -3220,6 +3298,7 @@ def receive_responses
32203298
resp = get_response
32213299
rescue Exception => e
32223300
synchronize do
3301+
state_logout!
32233302
@sock.close
32243303
@exception = e
32253304
end
@@ -3239,6 +3318,7 @@ def receive_responses
32393318
@tagged_response_arrival.broadcast
32403319
case resp.tag
32413320
when @logout_command_tag
3321+
state_logout!
32423322
return
32433323
when @continued_command_tag
32443324
@continuation_request_exception =
@@ -3248,13 +3328,15 @@ def receive_responses
32483328
when UntaggedResponse
32493329
record_untagged_response(resp)
32503330
if resp.name == "BYE" && @logout_command_tag.nil?
3331+
state_logout!
32513332
@sock.close
32523333
@exception = ByeResponseError.new(resp)
32533334
connection_closed = true
32543335
end
32553336
when ContinuationRequest
32563337
@continuation_request_arrival.signal
32573338
end
3339+
state_unselected! if resp in {data: {code: {name: "CLOSED"}}}
32583340
@response_handlers.each do |handler|
32593341
handler.call(resp)
32603342
end
@@ -3635,6 +3717,29 @@ def start_tls_session
36353717
end
36363718
end
36373719

3720+
def state_authenticated!(resp = nil)
3721+
synchronize do
3722+
@capabilities = capabilities_from_resp_code resp if resp
3723+
@connection_state = ConnectionState::Authenticated.new
3724+
end
3725+
end
3726+
3727+
def state_selected!
3728+
synchronize do
3729+
@connection_state = ConnectionState::Selected.new
3730+
end
3731+
end
3732+
3733+
def state_unselected!
3734+
state_authenticated! if connection_state.to_sym == :selected
3735+
end
3736+
3737+
def state_logout!
3738+
synchronize do
3739+
@connection_state = ConnectionState::Logout.new
3740+
end
3741+
end
3742+
36383743
def sasl_adapter
36393744
SASLAdapter.new(self, &method(:send_command_with_continuations))
36403745
end

lib/net/imap/connection_state.rb

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# frozen_string_literal: true
2+
3+
module Net
4+
class IMAP
5+
class ConnectionState < Net::IMAP::Data # :nodoc:
6+
def self.define(symbol, *attrs)
7+
symbol => Symbol
8+
state = super(*attrs)
9+
state.const_set :NAME, symbol
10+
state
11+
end
12+
13+
def symbol; self.class::NAME end
14+
def name; self.class::NAME.name end
15+
alias to_sym symbol
16+
17+
def deconstruct; [symbol, *super] end
18+
19+
def deconstruct_keys(names)
20+
hash = super
21+
hash[:symbol] = symbol if names.nil? || names.include?(:symbol)
22+
hash[:name] = name if names.nil? || names.include?(:name)
23+
hash
24+
end
25+
26+
def to_h(&block)
27+
hash = deconstruct_keys(nil)
28+
block ? hash.to_h(&block) : hash
29+
end
30+
31+
def not_authenticated?; to_sym == :not_authenticated end
32+
def authenticated?; to_sym == :authenticated end
33+
def selected?; to_sym == :selected end
34+
def logout?; to_sym == :logout end
35+
36+
NotAuthenticated = define(:not_authenticated)
37+
Authenticated = define(:authenticated)
38+
Selected = define(:selected)
39+
Logout = define(:logout)
40+
41+
class << self
42+
undef :define
43+
end
44+
freeze
45+
end
46+
47+
end
48+
end

0 commit comments

Comments
 (0)