Skip to content

Commit 27d0cb6

Browse files
authored
🔀 Merge pull request #416 from ruby/track-connection-state
✨ Track IMAP connection state
2 parents f89c8e9 + 518ceb3 commit 27d0cb6

File tree

3 files changed

+408
-23
lines changed

3 files changed

+408
-23
lines changed

‎lib/net/imap.rb

Lines changed: 134 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,18 @@ module Net
4343
# To work on the messages within a mailbox, the client must
4444
# first select that mailbox, using either #select or #examine
4545
# (for read-only access). Once the client has successfully
46-
# selected a mailbox, they enter the "_selected_" state, and that
46+
# selected a mailbox, they enter the +selected+ state, and that
4747
# mailbox becomes the _current_ mailbox, on which mail-item
4848
# related commands implicitly operate.
4949
#
50+
# === Connection state
51+
#
52+
# Once an IMAP connection is established, the connection is in one of four
53+
# states: <tt>not authenticated</tt>, +authenticated+, +selected+, and
54+
# +logout+. Most commands are valid only in certain states.
55+
#
56+
# See #connection_state.
57+
#
5058
# === Sequence numbers and UIDs
5159
#
5260
# Messages have two sorts of identifiers: message sequence
@@ -260,8 +268,9 @@ module Net
260268
#
261269
# - Net::IMAP.new: Creates a new \IMAP client which connects immediately and
262270
# waits for a successful server greeting before the method returns.
271+
# - #connection_state: Returns the connection state.
263272
# - #starttls: Asks the server to upgrade a clear-text connection to use TLS.
264-
# - #logout: Tells the server to end the session. Enters the "_logout_" state.
273+
# - #logout: Tells the server to end the session. Enters the +logout+ state.
265274
# - #disconnect: Disconnects the connection (without sending #logout first).
266275
# - #disconnected?: True if the connection has been closed.
267276
#
@@ -317,37 +326,36 @@ module Net
317326
# <em>In general, #capable? should be used rather than explicitly sending a
318327
# +CAPABILITY+ command to the server.</em>
319328
# - #noop: Allows the server to send unsolicited untagged #responses.
320-
# - #logout: Tells the server to end the session. Enters the "_logout_" state.
329+
# - #logout: Tells the server to end the session. Enters the +logout+ state.
321330
#
322331
# ==== Not Authenticated state
323332
#
324333
# In addition to the commands for any state, the following commands are valid
325-
# in the "<em>not authenticated</em>" state:
334+
# in the +not_authenticated+ state:
326335
#
327336
# - #starttls: Upgrades a clear-text connection to use TLS.
328337
#
329338
# <em>Requires the +STARTTLS+ capability.</em>
330339
# - #authenticate: Identifies the client to the server using the given
331340
# {SASL mechanism}[https://www.iana.org/assignments/sasl-mechanisms/sasl-mechanisms.xhtml]
332-
# and credentials. Enters the "_authenticated_" state.
341+
# and credentials. Enters the +authenticated+ state.
333342
#
334343
# <em>The server should list <tt>"AUTH=#{mechanism}"</tt> capabilities for
335344
# supported mechanisms.</em>
336345
# - #login: Identifies the client to the server using a plain text password.
337-
# Using #authenticate is generally preferred. Enters the "_authenticated_"
338-
# state.
346+
# Using #authenticate is preferred. Enters the +authenticated+ state.
339347
#
340348
# <em>The +LOGINDISABLED+ capability</em> <b>must NOT</b> <em>be listed.</em>
341349
#
342350
# ==== Authenticated state
343351
#
344352
# In addition to the commands for any state, the following commands are valid
345-
# in the "_authenticated_" state:
353+
# in the +authenticated+ state:
346354
#
347355
# - #enable: Enables backwards incompatible server extensions.
348356
# <em>Requires the +ENABLE+ or +IMAP4rev2+ capability.</em>
349-
# - #select: Open a mailbox and enter the "_selected_" state.
350-
# - #examine: Open a mailbox read-only, and enter the "_selected_" state.
357+
# - #select: Open a mailbox and enter the +selected+ state.
358+
# - #examine: Open a mailbox read-only, and enter the +selected+ state.
351359
# - #create: Creates a new mailbox.
352360
# - #delete: Permanently remove a mailbox.
353361
# - #rename: Change the name of a mailbox.
@@ -369,12 +377,12 @@ module Net
369377
#
370378
# ==== Selected state
371379
#
372-
# In addition to the commands for any state and the "_authenticated_"
373-
# commands, the following commands are valid in the "_selected_" state:
380+
# In addition to the commands for any state and the +authenticated+
381+
# commands, the following commands are valid in the +selected+ state:
374382
#
375-
# - #close: Closes the mailbox and returns to the "_authenticated_" state,
383+
# - #close: Closes the mailbox and returns to the +authenticated+ state,
376384
# expunging deleted messages, unless the mailbox was opened as read-only.
377-
# - #unselect: Closes the mailbox and returns to the "_authenticated_" state,
385+
# - #unselect: Closes the mailbox and returns to the +authenticated+ state,
378386
# without expunging any messages.
379387
# <em>Requires the +UNSELECT+ or +IMAP4rev2+ capability.</em>
380388
# - #expunge: Permanently removes messages which have the Deleted flag set.
@@ -395,7 +403,7 @@ module Net
395403
#
396404
# ==== Logout state
397405
#
398-
# No \IMAP commands are valid in the "_logout_" state. If the socket is still
406+
# No \IMAP commands are valid in the +logout+ state. If the socket is still
399407
# open, Net::IMAP will close it after receiving server confirmation.
400408
# Exceptions will be raised by \IMAP commands that have already started and
401409
# are waiting for a response, as well as any that are called after logout.
@@ -449,7 +457,7 @@ module Net
449457
# ==== RFC3691: +UNSELECT+
450458
# Folded into IMAP4rev2[https://www.rfc-editor.org/rfc/rfc9051] and also included
451459
# above with {Core IMAP commands}[rdoc-ref:Net::IMAP@Core+IMAP+commands].
452-
# - #unselect: Closes the mailbox and returns to the "_authenticated_" state,
460+
# - #unselect: Closes the mailbox and returns to the +authenticated+ state,
453461
# without expunging any messages.
454462
#
455463
# ==== RFC4314: +ACL+
@@ -752,9 +760,10 @@ class IMAP < Protocol
752760
"UTF8=ONLY" => "UTF8=ACCEPT",
753761
}.freeze
754762

755-
autoload :SASL, File.expand_path("imap/sasl", __dir__)
756-
autoload :SASLAdapter, File.expand_path("imap/sasl_adapter", __dir__)
757-
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__)
758767

759768
include MonitorMixin
760769
if defined?(OpenSSL::SSL)
@@ -827,6 +836,67 @@ def idle_response_timeout; config.idle_response_timeout end
827836
# Returns +false+ for a plaintext connection.
828837
attr_reader :ssl_ctx_params
829838

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+
830900
# Creates a new Net::IMAP object and connects it to the specified
831901
# +host+.
832902
#
@@ -946,6 +1016,8 @@ def initialize(host, port: nil, ssl: nil,
9461016
@exception = nil
9471017
@greeting = nil
9481018
@capabilities = nil
1019+
@tls_verified = false
1020+
@connection_state = ConnectionState::NotAuthenticated.new
9491021

9501022
# Client Protocol Receiver
9511023
@parser = ResponseParser.new(config: @config)
@@ -967,7 +1039,6 @@ def initialize(host, port: nil, ssl: nil,
9671039
@logout_command_tag = nil
9681040

9691041
# Connection
970-
@tls_verified = false
9711042
@sock = tcp_socket(@host, @port)
9721043
start_tls_session if ssl_ctx
9731044
start_imap_connection
@@ -983,6 +1054,7 @@ def tls_verified?; @tls_verified end
9831054
# Related: #logout, #logout!
9841055
def disconnect
9851056
return if disconnected?
1057+
state_logout!
9861058
begin
9871059
begin
9881060
# try to call SSL::SSLSocket#io.
@@ -1368,7 +1440,7 @@ def starttls(**options)
13681440
# capabilities, they will be cached.
13691441
def authenticate(*args, sasl_ir: config.sasl_ir, **props, &callback)
13701442
sasl_adapter.authenticate(*args, sasl_ir: sasl_ir, **props, &callback)
1371-
.tap { @capabilities = capabilities_from_resp_code _1 }
1443+
.tap do state_authenticated! _1 end
13721444
end
13731445

13741446
# Sends a {LOGIN command [IMAP4rev1 §6.2.3]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.2.3]
@@ -1402,7 +1474,7 @@ def login(user, password)
14021474
raise LoginDisabledError
14031475
end
14041476
send_command("LOGIN", user, password)
1405-
.tap { @capabilities = capabilities_from_resp_code _1 }
1477+
.tap do state_authenticated! _1 end
14061478
end
14071479

14081480
# Sends a {SELECT command [IMAP4rev1 §6.3.1]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.3.1]
@@ -1442,8 +1514,10 @@ def select(mailbox, condstore: false)
14421514
args = ["SELECT", mailbox]
14431515
args << ["CONDSTORE"] if condstore
14441516
synchronize do
1517+
state_unselected! # implicitly closes current mailbox
14451518
@responses.clear
14461519
send_command(*args)
1520+
.tap do state_selected! end
14471521
end
14481522
end
14491523

@@ -1460,8 +1534,10 @@ def examine(mailbox, condstore: false)
14601534
args = ["EXAMINE", mailbox]
14611535
args << ["CONDSTORE"] if condstore
14621536
synchronize do
1537+
state_unselected! # implicitly closes current mailbox
14631538
@responses.clear
14641539
send_command(*args)
1540+
.tap do state_selected! end
14651541
end
14661542
end
14671543

@@ -1900,6 +1976,7 @@ def check
19001976
# Related: #unselect
19011977
def close
19021978
send_command("CLOSE")
1979+
.tap do state_authenticated! end
19031980
end
19041981

19051982
# Sends an {UNSELECT command [RFC3691 §2]}[https://www.rfc-editor.org/rfc/rfc3691#section-3]
@@ -1916,6 +1993,7 @@ def close
19161993
# [RFC3691[https://www.rfc-editor.org/rfc/rfc3691]].
19171994
def unselect
19181995
send_command("UNSELECT")
1996+
.tap do state_authenticated! end
19191997
end
19201998

19211999
# call-seq:
@@ -3174,6 +3252,7 @@ def start_imap_connection
31743252
@capabilities = capabilities_from_resp_code @greeting
31753253
@receiver_thread = start_receiver_thread
31763254
rescue Exception
3255+
state_logout!
31773256
@sock.close
31783257
raise
31793258
end
@@ -3182,7 +3261,10 @@ def get_server_greeting
31823261
greeting = get_response
31833262
raise Error, "No server greeting - connection closed" unless greeting
31843263
record_untagged_response_code greeting
3185-
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
31863268
greeting
31873269
end
31883270

@@ -3192,6 +3274,8 @@ def start_receiver_thread
31923274
rescue Exception => ex
31933275
@receiver_thread_exception = ex
31943276
# don't exit the thread with an exception
3277+
ensure
3278+
state_logout!
31953279
end
31963280
end
31973281

@@ -3214,6 +3298,7 @@ def receive_responses
32143298
resp = get_response
32153299
rescue Exception => e
32163300
synchronize do
3301+
state_logout!
32173302
@sock.close
32183303
@exception = e
32193304
end
@@ -3233,6 +3318,7 @@ def receive_responses
32333318
@tagged_response_arrival.broadcast
32343319
case resp.tag
32353320
when @logout_command_tag
3321+
state_logout!
32363322
return
32373323
when @continued_command_tag
32383324
@continuation_request_exception =
@@ -3242,13 +3328,15 @@ def receive_responses
32423328
when UntaggedResponse
32433329
record_untagged_response(resp)
32443330
if resp.name == "BYE" && @logout_command_tag.nil?
3331+
state_logout!
32453332
@sock.close
32463333
@exception = ByeResponseError.new(resp)
32473334
connection_closed = true
32483335
end
32493336
when ContinuationRequest
32503337
@continuation_request_arrival.signal
32513338
end
3339+
state_unselected! if resp in {data: {code: {name: "CLOSED"}}}
32523340
@response_handlers.each do |handler|
32533341
handler.call(resp)
32543342
end
@@ -3629,6 +3717,29 @@ def start_tls_session
36293717
end
36303718
end
36313719

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+
36323743
def sasl_adapter
36333744
SASLAdapter.new(self, &method(:send_command_with_continuations))
36343745
end

0 commit comments

Comments
 (0)