[geary/cherry-pick-297a59ca] Merge branch 'mjog/imap-connection-fixes' into 'mainline'



commit cc446150dd9bb4881fa297805e257b399bae2f20
Author: Michael Gratton <mike vee net>
Date:   Mon Mar 30 11:35:18 2020 +0000

    Merge branch 'mjog/imap-connection-fixes' into 'mainline'
    
    IMAP connection fixes
    
    See merge request GNOME/geary!479
    
    (cherry picked from commit 297a59ca80cebb577eae240b882c1b7ed316ceef)
    
    6d229501 Add a simple mock server for testing network code
    95c1916a Add unit test for Geary.Imap.ClientConnection
    339d8ae2 Add unit test for Geary.Imap.ClientSession
    d2724e1c Remove last vestiges of TCP graceful disconnect from IMAP code
    cc5efc5f Minor doc comment update
    7e77133b Update Geary.Imap.Command API
    62f1df12 Rework Geary.Imap.ClientConnection signal and error handling
    67e9a4cc Update Geary.Imap.Deserializer
    e0b973be Update Geary.Imap.Deserialiser implementation
    79ba094f Fix Geary.Imap.Command never receiving a response timeout
    cd924e55 Clean up Geary.Imap.ClientSession disconnect handling
    57a1ef3f Fix Geary.Imap.ClientSession::disconnect_async not working
    ca7cc04a Remove Geary.Imap.ClientSession lifecyle signals
    54156003 Add TestCase.assert_double
    13d43d41 Update Geary.Imap.ClientSession connect timeout handling
    53ce727c Minor fixes
    b46838f1 Update Geary.Imap.Capabilities handling
    7ac72379 Update Geary.Imap.ClientSession namespace handling
    9e01d8dc Remove Geary.Imap.ClientSession::server-data-received signal
    b53d5b24 Simply Geary.Imap.ClientService selected mailbox handling
    31260ab1 Fix Geary.Imap.ClientService sometimes not closing sessions
    73474513 Geary.Imap.Serialiser: Stop using GDataOutputStream
    52a9102f Geary.Imap.Serializer: Trivial doc comment update
    47945cbd Geary.Imap.ClientConnection: Simplify serisaliser buf handling
    79125d1c Geary.Imap.Serializer: Remove unused id and to_string members

 po/POTFILES.in                                     |   2 +-
 src/engine/api/geary-engine.vala                   |   6 +-
 .../imap-engine/imap-engine-minimal-folder.vala    |   2 +-
 src/engine/imap/api/imap-account-session.vala      |   5 +-
 .../imap/{response => api}/imap-capabilities.vala  |  47 +-
 src/engine/imap/api/imap-client-service.vala       | 113 ++--
 src/engine/imap/api/imap-session-object.vala       |   2 +-
 src/engine/imap/command/imap-command.vala          |   6 +-
 src/engine/imap/imap-error.vala                    |   7 +-
 src/engine/imap/response/imap-response-code.vala   |  16 +-
 src/engine/imap/response/imap-server-data.vala     |  20 +-
 .../imap/transport/imap-client-connection.vala     | 217 +++-----
 src/engine/imap/transport/imap-client-session.vala | 613 ++++++++++-----------
 src/engine/imap/transport/imap-deserializer.vala   |  73 ++-
 src/engine/imap/transport/imap-serializer.vala     |  66 ++-
 src/engine/meson.build                             |   2 +-
 src/engine/util/util-generic-capabilities.vala     |  82 +--
 src/engine/util/util-stream.vala                   |  74 ---
 test/engine/imap-db/imap-db-account-test.vala      |   4 +-
 .../transport/imap-client-connection-test.vala     | 160 ++++++
 .../imap/transport/imap-client-session-test.vala   | 415 ++++++++++++++
 .../imap/transport/imap-deserializer-test.vala     |   4 +-
 test/engine/util-timeout-manager-test.vala         |  10 +-
 test/integration/imap/client-session.vala          |   6 +-
 test/meson.build                                   |   3 +
 test/test-case.vala                                |   4 +
 test/test-engine.vala                              |  12 +-
 test/test-server.vala                              | 222 ++++++++
 28 files changed, 1438 insertions(+), 755 deletions(-)
---
diff --git a/po/POTFILES.in b/po/POTFILES.in
index 7c983e35..bd352aa2 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -188,6 +188,7 @@ src/engine/db/db-versioned-database.vala
 src/engine/imap/imap.vala
 src/engine/imap/imap-error.vala
 src/engine/imap/api/imap-account-session.vala
+src/engine/imap/api/imap-capabilities.vala
 src/engine/imap/api/imap-client-service.vala
 src/engine/imap/api/imap-email-flags.vala
 src/engine/imap/api/imap-email-properties.vala
@@ -299,7 +300,6 @@ src/engine/imap/parameter/imap-quoted-string-parameter.vala
 src/engine/imap/parameter/imap-root-parameters.vala
 src/engine/imap/parameter/imap-string-parameter.vala
 src/engine/imap/parameter/imap-unquoted-string-parameter.vala
-src/engine/imap/response/imap-capabilities.vala
 src/engine/imap/response/imap-continuation-response.vala
 src/engine/imap/response/imap-fetch-data-decoder.vala
 src/engine/imap/response/imap-fetched-data.vala
diff --git a/src/engine/api/geary-engine.vala b/src/engine/api/geary-engine.vala
index 3fee1063..157eb2b1 100644
--- a/src/engine/api/geary-engine.vala
+++ b/src/engine/api/geary-engine.vala
@@ -284,10 +284,12 @@ public class Geary.Engine : BaseObject {
             (security, cx) => account.untrusted_host(service, security, cx)
         );
 
-        Geary.Imap.ClientSession client = new Imap.ClientSession(endpoint);
+        var client = new Imap.ClientSession(endpoint);
         GLib.Error? imap_err = null;
         try {
-            yield client.connect_async(cancellable);
+            yield client.connect_async(
+                Imap.ClientSession.DEFAULT_GREETING_TIMEOUT_SEC, cancellable
+            );
         } catch (GLib.Error err) {
             imap_err = err;
         }
diff --git a/src/engine/imap-engine/imap-engine-minimal-folder.vala 
b/src/engine/imap-engine/imap-engine-minimal-folder.vala
index 923b8e71..3417f1da 100644
--- a/src/engine/imap-engine/imap-engine-minimal-folder.vala
+++ b/src/engine/imap-engine/imap-engine-minimal-folder.vala
@@ -770,7 +770,7 @@ private class Geary.ImapEngine.MinimalFolder : Geary.Folder, Geary.FolderSupport
             this.remote_wait_semaphore.reset();
         }
 
-        Imap.FolderSession session = this.remote_session;
+        Imap.FolderSession? session = this.remote_session;
         this.remote_session = null;
         if (session != null) {
             session.appended.disconnect(on_remote_appended);
diff --git a/src/engine/imap/api/imap-account-session.vala b/src/engine/imap/api/imap-account-session.vala
index ed713733..311283cc 100644
--- a/src/engine/imap/api/imap-account-session.vala
+++ b/src/engine/imap/api/imap-account-session.vala
@@ -46,11 +46,12 @@ internal class Geary.Imap.AccountSession : Geary.Imap.SessionObject {
     public async FolderPath get_default_personal_namespace(Cancellable? cancellable)
     throws Error {
         ClientSession session = claim_session();
-        if (session.personal_namespaces.is_empty) {
+        Gee.List<Namespace> personal = session.get_personal_namespaces();
+        if (personal.is_empty) {
             throw new ImapError.INVALID("No personal namespace found");
         }
 
-        Namespace ns = session.personal_namespaces[0];
+        Namespace ns = personal[0];
         string prefix = ns.prefix;
         string? delim = ns.delim;
         if (delim != null && prefix.has_suffix(delim)) {
diff --git a/src/engine/imap/response/imap-capabilities.vala b/src/engine/imap/api/imap-capabilities.vala
similarity index 59%
rename from src/engine/imap/response/imap-capabilities.vala
rename to src/engine/imap/api/imap-capabilities.vala
index 22179c9a..c38980d0 100644
--- a/src/engine/imap/response/imap-capabilities.vala
+++ b/src/engine/imap/api/imap-capabilities.vala
@@ -1,7 +1,9 @@
-/* Copyright 2016 Software Freedom Conservancy Inc.
+/*
+ * Copyright 2016 Software Freedom Conservancy Inc.
+ * Copyright 2019 Michael Gratton <mike vee net>
  *
  * This software is licensed under the GNU Lesser General Public License
- * (version 2.1 or later).  See the COPYING file in this distribution.
+ * (version 2.1 or later). See the COPYING file in this distribution.
  */
 
 public class Geary.Imap.Capabilities : Geary.GenericCapabilities {
@@ -13,6 +15,7 @@ public class Geary.Imap.Capabilities : Geary.GenericCapabilities {
     public const string COMPRESS = "COMPRESS";
     public const string DEFLATE_SETTING = "DEFLATE";
     public const string IDLE = "IDLE";
+    public const string IMAP4REV1 = "IMAP4rev1";
     public const string NAMESPACE = "NAMESPACE";
     public const string SPECIAL_USE = "SPECIAL-USE";
     public const string STARTTLS = "STARTTLS";
@@ -22,29 +25,49 @@ public class Geary.Imap.Capabilities : Geary.GenericCapabilities {
     public const string NAME_SEPARATOR = "=";
     public const string? VALUE_SEPARATOR = null;
 
-
+    /**
+     * The version of this set of capabilities for an IMAP session.
+     *
+     * The capabilities that an IMAP session offers changes over time,
+     * for example after login or STARTTLS. This property supports
+     * detecting these changes.
+     *
+     * @see ClientSession.capabilities
+     */
     public int revision { get; private set; }
 
 
     /**
-     * Creates an empty set of capabilities.  revision represents the different variations of
-     * capabilities that an IMAP session might offer (i.e. changes after login or STARTTLS, for
-     * example).
+     * Creates an empty set of capabilities.
      */
-    public Capabilities(int revision) {
-        base (NAME_SEPARATOR, VALUE_SEPARATOR);
-
-        this.revision = revision;
+    public Capabilities(StringParameter[] capabilities, int revision) {
+        this.empty(revision);
+        foreach (var cap in capabilities) {
+            parse_and_add_capability(cap.ascii);
+        }
     }
 
-    public bool add_parameter(StringParameter stringp) {
-        return parse_and_add_capability(stringp.ascii);
+    /**
+     * Creates an empty set of capabilities.
+     */
+    public Capabilities.empty(int revision) {
+        base(NAME_SEPARATOR, VALUE_SEPARATOR);
+        this.revision = revision;
     }
 
     public override string to_string() {
         return "#%d: %s".printf(revision, base.to_string());
     }
 
+    /**
+     * Indicates an IMAP session reported support for IMAP 4rev1.
+     *
+     * See [[https://tools.ietf.org/html/rfc2177]]
+     */
+    public bool supports_imap4rev1() {
+        return has_capability(IMAP4REV1);
+    }
+
     /**
      * Indicates the {@link ClientSession} reported support for IDLE.
      *
diff --git a/src/engine/imap/api/imap-client-service.vala b/src/engine/imap/api/imap-client-service.vala
index 26d4aa6d..7a8a7f84 100644
--- a/src/engine/imap/api/imap-client-service.vala
+++ b/src/engine/imap/api/imap-client-service.vala
@@ -222,14 +222,17 @@ internal class Geary.Imap.ClientService : Geary.ClientService {
             this.all_sessions.size > this.min_pool_size
         );
 
-        if (!this.is_running || this.discard_returned_sessions || too_many_free) {
-            yield disconnect_session(session);
-        } else if (yield check_session(session, false)) {
-            bool free = true;
-            MailboxSpecifier? mailbox = null;
-            ClientSession.ProtocolState proto = session.get_protocol_state(out mailbox);
+        bool disconnect = (
+            too_many_free ||
+            this.discard_returned_sessions ||
+            !this.is_running ||
+            !yield check_session(session, false)
+        );
+
+        if (!disconnect) {
             // If the session has a mailbox selected, close it before
             // adding it back to the pool
+            ClientSession.ProtocolState proto = session.get_protocol_state();
             if (proto == ClientSession.ProtocolState.SELECTED ||
                 proto == ClientSession.ProtocolState.SELECTING) {
                 // always close mailbox to return to authorized state
@@ -238,32 +241,20 @@ internal class Geary.Imap.ClientService : Geary.ClientService {
                 } catch (ImapError imap_error) {
                     debug("Error attempting to close released session %s: %s",
                           session.to_string(), imap_error.message);
-                    free = false;
+                    disconnect = true;
                 }
-
-                // Double check the session after closing it
-                switch (session.get_protocol_state(null)) {
-                case AUTHORIZED:
-                    // This is the desired state, so all good
-                    break;
-
-                case NOT_CONNECTED:
-                    // No longer connected, so just drop it
-                    free = false;
-                    break;
-
-                default:
+                if (session.get_protocol_state() != AUTHORIZED) {
                     // Closing it didn't leave it in the desired
-                    // state, so log out and drop it
-                    yield disconnect_session(session);
-                    free = false;
-                    break;
+                    // state, so drop it
+                    disconnect = true;
                 }
             }
 
-            if (free) {
+            if (!disconnect) {
                 debug("Unreserving session %s", session.to_string());
                 this.free_queue.send(session);
+            } else {
+                yield disconnect_session(session);
             }
         }
     }
@@ -381,7 +372,7 @@ internal class Geary.Imap.ClientService : Geary.ClientService {
     /** Determines if a session is valid, disposing of it if not. */
     private async bool check_session(ClientSession target, bool claiming) {
         bool valid = false;
-        switch (target.get_protocol_state(null)) {
+        switch (target.get_protocol_state()) {
         case ClientSession.ProtocolState.AUTHORIZED:
         case ClientSession.ProtocolState.CLOSING_MAILBOX:
             valid = true;
@@ -396,15 +387,6 @@ internal class Geary.Imap.ClientService : Geary.ClientService {
             }
             break;
 
-        case ClientSession.ProtocolState.NOT_CONNECTED:
-            // Already disconnected, so drop it on the floor
-            try {
-                yield remove_session_async(target);
-            } catch (Error err) {
-                debug("Error removing unconnected session: %s", err.message);
-            }
-            break;
-
         default:
             yield disconnect_session(target);
             break;
@@ -447,11 +429,13 @@ internal class Geary.Imap.ClientService : Geary.ClientService {
 
         ClientSession new_session = new ClientSession(remote);
         new_session.set_logging_parent(this);
-        yield new_session.connect_async(cancellable);
+        yield new_session.connect_async(
+            ClientSession.DEFAULT_GREETING_TIMEOUT_SEC, cancellable
+        );
 
         try {
             yield new_session.initiate_session_async(login, cancellable);
-        } catch (Error err) {
+        } catch (GLib.Error err) {
             // need to disconnect before throwing error ... don't
             // honor Cancellable here, it's important to disconnect
             // the client before dropping the ref
@@ -504,44 +488,43 @@ internal class Geary.Imap.ClientService : Geary.ClientService {
     }
 
     private async void disconnect_session(ClientSession session) {
-        debug("Logging out session: %s", session.to_string());
-
-        // Log out before removing the session since close() only
-        // hangs around until all sessions have been removed before
-        // exiting.
-        try {
-            yield session.logout_async(this.close_cancellable);
+        if (session.get_protocol_state() != NOT_CONNECTED) {
+            debug("Logging out session: %s", session.to_string());
+            // No need to remove it after logging out, the
+            // disconnected handler will do that for us.
+            try {
+                yield session.logout_async(this.close_cancellable);
+            } catch (GLib.Error err) {
+                debug("Error logging out of session: %s", err.message);
+                yield force_disconnect_session(session);
+                }
+        } else {
             yield remove_session_async(session);
-        } catch (GLib.Error err) {
-            debug("Error logging out of session: %s", err.message);
-            yield force_disconnect_session(session);
         }
-
     }
 
     private async void force_disconnect_session(ClientSession session) {
         debug("Dropping session: %s", session.to_string());
-
-        try {
-            yield remove_session_async(session);
-        } catch (Error err) {
-            debug("Error removing session: %s", err.message);
-        }
+        yield remove_session_async(session);
 
         // Don't wait for this to finish because we don't want to
         // block claiming a new session, shutdown, etc.
         session.disconnect_async.begin(null);
     }
 
-    private async bool remove_session_async(ClientSession session) throws Error {
+    private async bool remove_session_async(ClientSession session) {
         // Ensure the session isn't held on to, anywhere
 
         this.free_queue.revoke(session);
 
         bool removed = false;
-        yield this.sessions_mutex.execute_locked(() => {
-                removed = this.all_sessions.remove(session);
-            });
+        try {
+            yield this.sessions_mutex.execute_locked(() => {
+                    removed = this.all_sessions.remove(session);
+                });
+        } catch (GLib.Error err) {
+            debug("Error removing session: %s", err.message);
+        }
 
         if (removed) {
             session.disconnected.disconnect(on_disconnected);
@@ -549,21 +532,15 @@ internal class Geary.Imap.ClientService : Geary.ClientService {
         return removed;
     }
 
-    private void on_disconnected(ClientSession session, ClientSession.DisconnectReason reason) {
+    private void on_disconnected(ClientSession session,
+                                 ClientSession.DisconnectReason reason) {
         debug(
-            "Session unexpected disconnect: %s: %s",
+            "Session disconnected: %s: %s",
             session.to_string(), reason.to_string()
         );
         this.remove_session_async.begin(
             session,
-            (obj, res) => {
-                try {
-                    this.remove_session_async.end(res);
-                } catch (Error err) {
-                    debug("Error removing disconnected session: %s",
-                          err.message);
-                }
-            }
+            (obj, res) => { this.remove_session_async.end(res); }
         );
     }
 
diff --git a/src/engine/imap/api/imap-session-object.vala b/src/engine/imap/api/imap-session-object.vala
index 248f9b70..33365b06 100644
--- a/src/engine/imap/api/imap-session-object.vala
+++ b/src/engine/imap/api/imap-session-object.vala
@@ -103,7 +103,7 @@ public abstract class Geary.Imap.SessionObject : BaseObject, Logging.Source {
     }
 
     private void on_disconnected(ClientSession.DisconnectReason reason) {
-        debug("DISCONNECTED %s", reason.to_string());
+        debug("Disconnected %s", reason.to_string());
 
         close();
         disconnected(reason);
diff --git a/src/engine/imap/command/imap-command.vala b/src/engine/imap/command/imap-command.vala
index 570fe3ba..58163671 100644
--- a/src/engine/imap/command/imap-command.vala
+++ b/src/engine/imap/command/imap-command.vala
@@ -17,7 +17,7 @@
  *
  * See [[http://tools.ietf.org/html/rfc3501#section-6]]
  */
-public class Geary.Imap.Command : BaseObject {
+public abstract class Geary.Imap.Command : BaseObject {
 
     /**
      * Default timeout to wait for a server response for a command.
@@ -97,7 +97,7 @@ public class Geary.Imap.Command : BaseObject {
      *
      * @see Tag
      */
-    public Command(string name, string[]? args = null) {
+    protected Command(string name, string[]? args = null) {
         this.tag = Tag.get_unassigned();
         this.name = name;
         if (args != null) {
@@ -250,7 +250,7 @@ public class Geary.Imap.Command : BaseObject {
      * cancelled, if the command timed out, or if the command's
      * response was bad.
      */
-    public async void wait_until_complete(GLib.Cancellable cancellable)
+    public async void wait_until_complete(GLib.Cancellable? cancellable)
         throws GLib.Error {
         yield this.complete_lock.wait_async(cancellable);
 
diff --git a/src/engine/imap/imap-error.vala b/src/engine/imap/imap-error.vala
index df1821df..8e467556 100644
--- a/src/engine/imap/imap-error.vala
+++ b/src/engine/imap/imap-error.vala
@@ -63,6 +63,11 @@ public errordomain Geary.ImapError {
 
     /**
      * The remote IMAP server not currently available.
+     *
+     * This does not indicate a network error, rather it indicates a
+     * connection to the server was established but the server
+     * indicated it is not currently servicing the connection.
      */
-    UNAVAILABLE
+    UNAVAILABLE;
+
 }
diff --git a/src/engine/imap/response/imap-response-code.vala 
b/src/engine/imap/response/imap-response-code.vala
index 2f283332..af6e799a 100644
--- a/src/engine/imap/response/imap-response-code.vala
+++ b/src/engine/imap/response/imap-response-code.vala
@@ -69,24 +69,22 @@ public class Geary.Imap.ResponseCode : Geary.Imap.ListParameter {
     /**
      * Parses the {@link ResponseCode} into {@link Capabilities}, if possible.
      *
-     * Since Capabilities are revised with various {@link ClientSession} states, this method accepts
-     * a ref to an int that will be incremented after handed to the Capabilities constructor.  This
-     * can be used to track the revision of capabilities seen on the connection.
-     *
      * @throws ImapError.INVALID if Capability was not specified.
      */
-    public Capabilities get_capabilities(ref int next_revision) throws ImapError {
+    public Capabilities get_capabilities(int revision) throws ImapError {
         if (!get_response_code_type().is_value(ResponseCodeType.CAPABILITY))
             throw new ImapError.INVALID("Not CAPABILITY response code: %s", to_string());
 
-        Capabilities capabilities = new Capabilities(next_revision++);
+        var params = new StringParameter[this.size];
+        int count = 0;
         for (int ctr = 1; ctr < size; ctr++) {
             StringParameter? param = get_if_string(ctr);
-            if (param != null)
-                capabilities.add_parameter(param);
+            if (param != null) {
+                params[count++] = param;
+            }
         }
 
-        return capabilities;
+        return new Capabilities(params[0:count], revision);
     }
 
     /**
diff --git a/src/engine/imap/response/imap-server-data.vala b/src/engine/imap/response/imap-server-data.vala
index a4202956..29aee4b5 100644
--- a/src/engine/imap/response/imap-server-data.vala
+++ b/src/engine/imap/response/imap-server-data.vala
@@ -50,24 +50,22 @@ public class Geary.Imap.ServerData : ServerResponse {
     /**
      * Parses the {@link ServerData} into {@link Capabilities}, if possible.
      *
-     * Since Capabilities are revised with various {@link ClientSession} states, this method accepts
-     * a ref to an int that will be incremented after handed to the Capabilities constructor.  This
-     * can be used to track the revision of capabilities seen on the connection.
-     *
      * @throws ImapError.INVALID if not a Capability.
      */
-    public Capabilities get_capabilities(ref int next_revision) throws ImapError {
-        if (server_data_type != ServerDataType.CAPABILITY)
+    public Capabilities get_capabilities(int revision) throws ImapError {
+        if (this.server_data_type != ServerDataType.CAPABILITY)
             throw new ImapError.INVALID("Not CAPABILITY data: %s", to_string());
 
-        Capabilities capabilities = new Capabilities(next_revision++);
-        for (int ctr = 2; ctr < size; ctr++) {
+        var params = new StringParameter[this.size];
+        int count = 0;
+        for (int ctr = 1; ctr < size; ctr++) {
             StringParameter? param = get_if_string(ctr);
-            if (param != null)
-                capabilities.add_parameter(param);
+            if (param != null) {
+                params[count++] = param;
+            }
         }
 
-        return capabilities;
+        return new Capabilities(params[0:count], revision);
     }
 
     /**
diff --git a/src/engine/imap/transport/imap-client-connection.vala 
b/src/engine/imap/transport/imap-client-connection.vala
index 7513adb7..8d7e54d9 100644
--- a/src/engine/imap/transport/imap-client-connection.vala
+++ b/src/engine/imap/transport/imap-client-connection.vala
@@ -40,12 +40,6 @@ public class Geary.Imap.ClientConnection : BaseObject, Logging.Source {
     private static int next_cx_id = 0;
 
 
-    /**
-     * This identifier is used only for debugging, to differentiate connections from one another
-     * in logs and debug output.
-     */
-    public int cx_id { get; private set; }
-
     /**
      * Determines if the connection will use IMAP IDLE when idle.
      *
@@ -69,11 +63,10 @@ public class Geary.Imap.ClientConnection : BaseObject, Logging.Source {
     private weak Logging.Source? _logging_parent = null;
 
     private Geary.Endpoint endpoint;
-    private SocketConnection? cx = null;
-    private IOStream? ios = null;
-    private Serializer? ser = null;
-    private BufferedOutputStream? ser_buffer = null;
-    private Deserializer? des = null;
+    private int cx_id;
+    private IOStream? cx = null;
+    private Deserializer? deserializer = null;
+    private Serializer? serializer = null;
 
     private int tag_counter = 0;
     private char tag_prefix = 'a';
@@ -89,14 +82,6 @@ public class Geary.Imap.ClientConnection : BaseObject, Logging.Source {
     private GLib.Cancellable? open_cancellable = null;
 
 
-    public virtual signal void connected() {
-        debug("Connected to %s", endpoint.to_string());
-    }
-
-    public virtual signal void disconnected() {
-        debug("Disconnected from %s", endpoint.to_string());
-    }
-
     public virtual signal void sent_command(Command cmd) {
         debug("SEND: %s", cmd.to_string());
     }
@@ -113,34 +98,14 @@ public class Geary.Imap.ClientConnection : BaseObject, Logging.Source {
         debug("RECV: %s", continuation_response.to_string());
     }
 
-    public virtual signal void received_bytes(size_t bytes) {
-        // this generates a *lot* of debug logging if one was placed here, so it's not
-    }
-
-    public virtual signal void received_bad_response(RootParameters root,
-                                                     ImapError err) {
-        warning("Received bad response: %s", err.message);
-    }
-
-    public virtual signal void received_eos() {
-        debug("Received eos");
-    }
-
-    public virtual signal void send_failure(Error err) {
-        warning("Send failure: %s", err.message);
-    }
+    public signal void received_bytes(size_t bytes);
 
-    public virtual signal void receive_failure(Error err) {
-        warning("Receive failure: %s", err.message);
-    }
+    public signal void received_bad_response(RootParameters root,
+                                                     ImapError err);
 
-    public virtual signal void deserialize_failure(Error err) {
-        warning("Deserialize failure: %s", err.message);
-    }
+    public signal void send_failure(Error err);
 
-    public virtual signal void close_error(Error err) {
-        warning("Close error: %s", err.message);
-    }
+    public signal void receive_failure(GLib.Error err);
 
 
     public ClientConnection(
@@ -158,8 +123,9 @@ public class Geary.Imap.ClientConnection : BaseObject, Logging.Source {
     /** Returns the remote address of this connection, if any. */
     public GLib.SocketAddress? get_remote_address() throws GLib.Error {
         GLib.SocketAddress? addr = null;
-        if (cx != null) {
-            addr = cx.get_remote_address();
+        var tcp_cx = getTcpConnection();
+        if (tcp_cx != null) {
+            addr = tcp_cx.get_remote_address();
         }
         return addr;
     }
@@ -167,8 +133,9 @@ public class Geary.Imap.ClientConnection : BaseObject, Logging.Source {
     /** Returns the local address of this connection, if any. */
     public SocketAddress? get_local_address() throws GLib.Error {
         GLib.SocketAddress? addr = null;
-        if (cx != null) {
-            addr = cx.get_local_address();
+        var tcp_cx = getTcpConnection();
+        if (tcp_cx != null) {
+            addr = tcp_cx.get_local_address();
         }
         return addr;
     }
@@ -209,30 +176,22 @@ public class Geary.Imap.ClientConnection : BaseObject, Logging.Source {
         if (this.cx != null) {
             throw new ImapError.ALREADY_CONNECTED("Client already connected");
         }
-
-        this.cx = yield endpoint.connect_async(cancellable);
-        this.ios = cx;
+        this.cx = yield this.endpoint.connect_async(cancellable);
 
         this.pending_queue.clear();
         this.sent_queue.clear();
 
-        connected();
-
         try {
             yield open_channels_async();
-        } catch (Error err) {
-            // if this fails, need to close connection because the caller will not call
-            // disconnect_async()
+        } catch (GLib.Error err) {
+            // if this fails, need to close connection because the
+            // caller will not call disconnect_async()
             try {
                 yield cx.close_async();
-            } catch (Error close_err) {
+            } catch (GLib.Error close_err) {
                 // ignored
             }
-
             this.cx = null;
-            this.ios = null;
-
-            receive_failure(err);
 
             throw err;
         }
@@ -243,17 +202,14 @@ public class Geary.Imap.ClientConnection : BaseObject, Logging.Source {
     }
 
     public async void disconnect_async(Cancellable? cancellable = null) throws Error {
-        if (cx == null)
+        if (this.cx == null)
             return;
 
         this.idle_timer.reset();
 
         // To guard against reentrancy
-        SocketConnection close_cx = cx;
-        cx = null;
-
-        // close the Serializer and Deserializer
-        yield close_channels_async(cancellable);
+        GLib.IOStream old_cx = this.cx;
+        this.cx = null;
 
         // Cancel any pending commands
         foreach (Command pending in this.pending_queue.get_all()) {
@@ -263,20 +219,14 @@ public class Geary.Imap.ClientConnection : BaseObject, Logging.Source {
         this.pending_queue.clear();
 
         // close the actual streams and the connection itself
-        Error? close_err = null;
-        try {
-            yield ios.close_async(Priority.DEFAULT, cancellable);
-            yield close_cx.close_async(Priority.DEFAULT, cancellable);
-        } catch (Error err) {
-            close_err = err;
-        } finally {
-            ios = null;
-
-            if (close_err != null) {
-                close_error(close_err);
-            }
+        yield close_channels_async(cancellable);
+        yield old_cx.close_async(Priority.DEFAULT, cancellable);
 
-            disconnected();
+        var tls_cx = old_cx as GLib.TlsConnection;
+        if (tls_cx != null && !tls_cx.base_io_stream.is_closed()) {
+            yield tls_cx.base_io_stream.close_async(
+                Priority.DEFAULT, cancellable
+            );
         }
     }
 
@@ -300,9 +250,7 @@ public class Geary.Imap.ClientConnection : BaseObject, Logging.Source {
         yield close_channels_async(cancellable);
 
         // wrap connection with TLS connection
-        TlsClientConnection tls_cx = yield endpoint.starttls_handshake_async(cx, cancellable);
-
-        ios = tls_cx;
+        this.cx = yield endpoint.starttls_handshake_async(this.cx, cancellable);
 
         // re-open Serializer/Deserializer with the new streams
         yield open_channels_async();
@@ -352,33 +300,39 @@ public class Geary.Imap.ClientConnection : BaseObject, Logging.Source {
         this._logging_parent = parent;
     }
 
-    private async void open_channels_async() throws Error {
-        assert(ios != null);
-        assert(ser == null);
-        assert(des == null);
+    private GLib.TcpConnection? getTcpConnection() {
+        var cx = this.cx;
+        var tls_cx = cx as GLib.TlsConnection;
+        if (tls_cx != null) {
+            cx = tls_cx.base_io_stream;
+        }
+        return cx as TcpConnection;
+    }
 
+    private async void open_channels_async() throws Error {
         this.open_cancellable = new GLib.Cancellable();
 
-        // Not buffering the Deserializer because it uses a DataInputStream, which is buffered
-        ser_buffer = new BufferedOutputStream(ios.output_stream);
-        ser_buffer.set_close_base_stream(false);
-
-        // Use ClientConnection cx_id for debugging aid with Serializer/Deserializer
         string id = "%04d".printf(cx_id);
-        ser = new Serializer(id, ser_buffer);
-        des = new Deserializer(id, ios.input_stream);
 
-        des.parameters_ready.connect(on_parameters_ready);
-        des.bytes_received.connect(on_bytes_received);
-        des.receive_failure.connect(on_receive_failure);
-        des.deserialize_failure.connect(on_deserialize_failure);
-        des.eos.connect(on_eos);
+        var serializer_buffer = new GLib.BufferedOutputStream(
+            this.cx.output_stream
+        );
+        serializer_buffer.set_close_base_stream(false);
+        this.serializer = new Serializer(serializer_buffer);
+
+        // Not buffering the Deserializer because it uses a
+        // DataInputStream, which is already buffered
+        this.deserializer = new Deserializer(id, this.cx.input_stream);
+        this.deserializer.bytes_received.connect(on_bytes_received);
+        this.deserializer.deserialize_failure.connect(on_deserialize_failure);
+        this.deserializer.end_of_stream.connect(on_eos);
+        this.deserializer.parameters_ready.connect(on_parameters_ready);
+        this.deserializer.receive_failure.connect(on_receive_failure);
+        yield this.deserializer.start_async();
 
         // Start this running in the "background", it will stop when
         // open_cancellable is cancelled
         this.send_loop.begin();
-
-        yield des.start_async();
     }
 
     /** Disconnect and deallocates the Serializer and Deserializer. */
@@ -392,26 +346,21 @@ public class Geary.Imap.ClientConnection : BaseObject, Logging.Source {
         }
         this.sent_queue.clear();
 
-        // disconnect from Deserializer before yielding to stop it
-        if (des != null) {
-            des.parameters_ready.disconnect(on_parameters_ready);
-            des.bytes_received.disconnect(on_bytes_received);
-            des.receive_failure.disconnect(on_receive_failure);
-            des.deserialize_failure.disconnect(on_deserialize_failure);
-            des.eos.disconnect(on_eos);
-
-            yield des.stop_async();
+        if (this.serializer != null) {
+            yield this.serializer.close_stream(cancellable);
+            this.serializer = null;
         }
-        des = null;
-        ser = null;
-        // Close the Serializer's buffered stream after it as been
-        // deallocated so it can't possibly write to the stream again,
-        // and so the stream's async thread doesn't attempt to flush
-        // its buffers from its finaliser at some later unspecified
-        // point, possibly writing to an invalid underlying stream.
-        if (ser_buffer != null) {
-            yield ser_buffer.close_async(GLib.Priority.DEFAULT, cancellable);
-            ser_buffer = null;
+
+        var deserializer = this.deserializer;
+        if (deserializer != null) {
+            deserializer.bytes_received.disconnect(on_bytes_received);
+            deserializer.deserialize_failure.disconnect(on_deserialize_failure);
+            deserializer.end_of_stream.disconnect(on_eos);
+            deserializer.parameters_ready.disconnect(on_parameters_ready);
+            deserializer.receive_failure.disconnect(on_receive_failure);
+
+            yield deserializer.stop_async();
+            this.deserializer = null;
         }
     }
 
@@ -454,7 +403,7 @@ public class Geary.Imap.ClientConnection : BaseObject, Logging.Source {
                 // Check the queue is still empty after sending the
                 // command, since that might have changed.
                 if (this.pending_queue.is_empty) {
-                    yield this.ser.flush_stream(cancellable);
+                    yield this.serializer.flush_stream(cancellable);
                 }
             } catch (GLib.Error err) {
                 if (!(err is GLib.IOError.CANCELLED)) {
@@ -482,12 +431,13 @@ public class Geary.Imap.ClientConnection : BaseObject, Logging.Source {
 
             // Set timeout per session policy
             command.response_timeout = this.command_timeout;
+            command.response_timed_out.connect(on_command_timeout);
 
             this.current_command = command;
             this.sent_queue.add(command);
-            yield command.send(this.ser, cancellable);
+            yield command.send(this.serializer, cancellable);
             sent_command(command);
-            yield command.send_wait(this.ser, cancellable);
+            yield command.send_wait(this.serializer, cancellable);
         } catch (GLib.Error err) {
             ser_error = err;
         }
@@ -586,32 +536,29 @@ public class Geary.Imap.ClientConnection : BaseObject, Logging.Source {
         received_bytes(bytes);
     }
 
+    private void on_eos() {
+        receive_failure(
+            new ImapError.NOT_CONNECTED(
+                "End of stream reading from %s", to_string()
+            )
+        );
+    }
+
     private void on_receive_failure(Error err) {
         receive_failure(err);
     }
 
     private void on_deserialize_failure() {
-        deserialize_failure(
+        receive_failure(
             new ImapError.PARSE_ERROR(
                 "Unable to deserialize from %s", to_string()
             )
         );
     }
 
-    private void on_eos() {
-        received_eos();
-    }
-
     private void on_command_timeout(Command command) {
         this.sent_queue.remove(command);
         command.response_timed_out.disconnect(on_command_timeout);
-
-        // turn off graceful disconnect ... if the connection is hung,
-        // don't want to be stalled trying to flush the pipe
-        TcpConnection? tcp_cx = cx as TcpConnection;
-        if (tcp_cx != null)
-            tcp_cx.set_graceful_disconnect(false);
-
         receive_failure(
             new ImapError.TIMED_OUT(
                 "No response to command after %u seconds: %s",
diff --git a/src/engine/imap/transport/imap-client-session.vala 
b/src/engine/imap/transport/imap-client-session.vala
index a40c5986..fabee175 100644
--- a/src/engine/imap/transport/imap-client-session.vala
+++ b/src/engine/imap/transport/imap-client-session.vala
@@ -74,7 +74,9 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
     /** Default keep-alive interval when not in the Selected state. */
     public const uint DEFAULT_UNSELECTED_KEEPALIVE_SEC = RECOMMENDED_KEEPALIVE_SEC;
 
-    private const uint GREETING_TIMEOUT_SEC = Command.DEFAULT_RESPONSE_TIMEOUT_SEC;
+    /** Default time to wait for the server greeting when connecting. */
+    public const uint DEFAULT_GREETING_TIMEOUT_SEC =
+        Command.DEFAULT_RESPONSE_TIMEOUT_SEC;
 
 
     /**
@@ -193,12 +195,14 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
     private enum Event {
         // user-initiated events
         CONNECT,
+        DISCONNECT,
+
+        // command-initiated events
         LOGIN,
         SEND_CMD,
         SELECT,
         CLOSE_MAILBOX,
         LOGOUT,
-        DISCONNECT,
 
         // server events
         CONNECTED,
@@ -224,26 +228,35 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
         state_to_string, event_to_string);
 
     /**
-     * {@link ClientSession} tracks server extensions reported via the CAPABILITY server data
-     * response.
+     * Set of IMAP extensions reported as being supported by the server.
      *
-     * ClientSession stores the last seen list as a service for users and uses it internally
-     * (specifically for IDLE support).
+     * The capabilities that an IMAP session offers changes over time,
+     * for example after login or STARTTLS. The instance assigned to
+     * this property will change as these change and the instance's
+     * {@link Capabilities.revision} property will also monotonically
+     * increase over the lifetime of a single session.
      */
-    public Capabilities capabilities { get; private set; default = new Capabilities(0); }
+    public Capabilities capabilities {
+        get; private set; default = new Capabilities.empty(0);
+    }
 
     /** Determines if this session supports the IMAP IDLE extension. */
     public bool is_idle_supported {
         get { return this.capabilities.has_capability(Capabilities.IDLE); }
     }
 
+    /** The currently selected mailbox, if any. */
+    public MailboxSpecifier? selected_mailbox = null;
+
     /**
-     * Determines when the last successful command response was received.
+     * Specifies if the current selected state is readonly.
      *
-     * Returns the system wall clock time the last successful command
-     * response was received, in microseconds since the UNIX epoch.
+     * This property specifies if the current selected state was
+     * entered by a SELECT command -- in which case mailbox access is
+     * read-write, or by an EXAMINE command -- in which case mailbox
+     * access is read-only.
      */
-    public int64 last_seen = 0;
+    public bool selected_readonly = false;
 
     /** {@inheritDoc} */
     public Logging.Flag logging_flags {
@@ -254,6 +267,14 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
     public Logging.Source? logging_parent { get { return _logging_parent; } }
     private weak Logging.Source? _logging_parent = null;
 
+    /**
+     * Determines when the last successful command response was received.
+     *
+     * Returns the system wall clock time the last successful command
+     * response was received, in microseconds since the UNIX epoch.
+     */
+    internal int64 last_seen { get; private set; default = 0; }
+
     // While the following inbox and namespace data should be server
     // specific, there is a small chance they will differ between
     // connections if the connections connect to different servers in
@@ -263,25 +284,21 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
     // initiate_session_async() has successfully completed.
 
     /** Records the actual name and delimiter used for the inbox */
-    internal MailboxInformation? inbox = null;
+    internal MailboxInformation? inbox { get; private set; default = null; }
 
-    /** The locations personal mailboxes on this  connection. */
-    internal Gee.List<Namespace> personal_namespaces = new Gee.ArrayList<Namespace>();
+    // Locations personal mailboxes for this session
+    private Gee.List<Namespace> personal_namespaces = new Gee.ArrayList<Namespace>();
 
-    /** The locations of other user's mailboxes on this connection. */
-    internal Gee.List<Namespace> user_namespaces = new Gee.ArrayList<Namespace>();
-
-    /** The locations of shared mailboxes on this connection. */
-    internal Gee.List<Namespace> shared_namespaces = new Gee.ArrayList<Namespace>();
+    // Locations of other user's mailboxes for this session
+    private Gee.List<Namespace> user_namespaces = new Gee.ArrayList<Namespace>();
 
+    // The locations of shared mailboxes for this sesion
+    private Gee.List<Namespace> shared_namespaces = new Gee.ArrayList<Namespace>();
 
     private Endpoint imap_endpoint;
     private Geary.State.Machine fsm;
     private ClientConnection? cx = null;
 
-    private MailboxSpecifier? current_mailbox = null;
-    private bool current_mailbox_readonly = false;
-
     private uint keepalive_id = 0;
     private uint selected_keepalive_secs = 0;
     private uint unselected_keepalive_secs = 0;
@@ -291,7 +308,6 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
     private Nonblocking.Semaphore? connect_waiter = null;
     private Error? connect_err = null;
 
-    private int next_capabilities_revision = 1;
     private Gee.Map<string,Namespace> namespaces = new Gee.HashMap<string,Namespace>();
 
 
@@ -300,28 +316,12 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
     // Connection state changes
     //
 
-    public signal void connected();
-
-    public signal void session_denied(string? reason);
-
-    public signal void authorized();
-
-    public signal void logged_out();
-
-    public signal void login_failed(StatusResponse? response);
-
+    /** Emitted when the session is disconnected for any reason. */
     public signal void disconnected(DisconnectReason reason);
 
+    /** Emitted when an IMAP command status response is received. */
     public signal void status_response_received(StatusResponse status_response);
 
-    /**
-     * Fired after the specific {@link ServerData} signals (i.e. {@link capability}, {@link exists}
-     * {@link expunge}, etc.)
-     */
-    public signal void server_data_received(ServerData server_data);
-
-    public signal void capability(Capabilities capabilities);
-
     public signal void exists(int count);
 
     public signal void expunge(SequenceNumber seq_num);
@@ -343,15 +343,6 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
 
     public signal void status(StatusData status_data);
 
-    public signal void @namespace(NamespaceResponse namespace);
-
-    /**
-     * If the mailbox name is null it indicates the type of state change that has occurred
-     * (authorized -> selected/examined or vice-versa).  If new_name is null readonly should be
-     * ignored.
-     */
-    public signal void current_mailbox_changed(MailboxSpecifier? old_name, MailboxSpecifier? new_name,
-        bool readonly);
 
     public ClientSession(Endpoint imap_endpoint) {
         this.imap_endpoint = imap_endpoint;
@@ -366,12 +357,12 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
             new Geary.State.Mapping(State.NOT_CONNECTED, Event.DISCONNECT, Geary.State.nop),
 
             new Geary.State.Mapping(State.CONNECTING, Event.CONNECT, on_already_connected),
+            new Geary.State.Mapping(State.CONNECTING, Event.DISCONNECT, on_disconnect),
             new Geary.State.Mapping(State.CONNECTING, Event.LOGIN, on_early_command),
             new Geary.State.Mapping(State.CONNECTING, Event.SEND_CMD, on_early_command),
             new Geary.State.Mapping(State.CONNECTING, Event.SELECT, on_early_command),
             new Geary.State.Mapping(State.CONNECTING, Event.CLOSE_MAILBOX, on_early_command),
             new Geary.State.Mapping(State.CONNECTING, Event.LOGOUT, on_early_command),
-            new Geary.State.Mapping(State.CONNECTING, Event.DISCONNECT, on_disconnect),
             new Geary.State.Mapping(State.CONNECTING, Event.CONNECTED, on_connected),
             new Geary.State.Mapping(State.CONNECTING, Event.RECV_STATUS, on_connecting_recv_status),
             new Geary.State.Mapping(State.CONNECTING, Event.RECV_COMPLETION, on_dropped_response),
@@ -380,99 +371,96 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
             new Geary.State.Mapping(State.CONNECTING, Event.TIMEOUT, on_connecting_timeout),
 
             new Geary.State.Mapping(State.NOAUTH, Event.CONNECT, on_already_connected),
+            new Geary.State.Mapping(State.NOAUTH, Event.DISCONNECT, on_disconnect),
             new Geary.State.Mapping(State.NOAUTH, Event.LOGIN, on_login),
             new Geary.State.Mapping(State.NOAUTH, Event.SEND_CMD, on_send_command),
             new Geary.State.Mapping(State.NOAUTH, Event.SELECT, on_unauthenticated),
             new Geary.State.Mapping(State.NOAUTH, Event.CLOSE_MAILBOX, on_unauthenticated),
             new Geary.State.Mapping(State.NOAUTH, Event.LOGOUT, on_logout),
-            new Geary.State.Mapping(State.NOAUTH, Event.DISCONNECT, on_disconnect),
             new Geary.State.Mapping(State.NOAUTH, Event.RECV_STATUS, on_recv_status),
             new Geary.State.Mapping(State.NOAUTH, Event.RECV_COMPLETION, on_recv_status),
             new Geary.State.Mapping(State.NOAUTH, Event.SEND_ERROR, on_send_error),
             new Geary.State.Mapping(State.NOAUTH, Event.RECV_ERROR, on_recv_error),
 
             new Geary.State.Mapping(State.AUTHORIZING, Event.CONNECT, on_already_connected),
+            new Geary.State.Mapping(State.AUTHORIZING, Event.DISCONNECT, on_disconnect),
             new Geary.State.Mapping(State.AUTHORIZING, Event.LOGIN, on_logging_in),
             new Geary.State.Mapping(State.AUTHORIZING, Event.SEND_CMD, on_unauthenticated),
             new Geary.State.Mapping(State.AUTHORIZING, Event.SELECT, on_unauthenticated),
             new Geary.State.Mapping(State.AUTHORIZING, Event.CLOSE_MAILBOX, on_unauthenticated),
             new Geary.State.Mapping(State.AUTHORIZING, Event.LOGOUT, on_logout),
-            new Geary.State.Mapping(State.AUTHORIZING, Event.DISCONNECT, on_disconnect),
             new Geary.State.Mapping(State.AUTHORIZING, Event.RECV_STATUS, on_recv_status),
             new Geary.State.Mapping(State.AUTHORIZING, Event.RECV_COMPLETION, on_login_recv_completion),
             new Geary.State.Mapping(State.AUTHORIZING, Event.SEND_ERROR, on_send_error),
             new Geary.State.Mapping(State.AUTHORIZING, Event.RECV_ERROR, on_recv_error),
 
             new Geary.State.Mapping(State.AUTHORIZED, Event.CONNECT, on_already_connected),
+            new Geary.State.Mapping(State.AUTHORIZED, Event.DISCONNECT, on_disconnect),
             new Geary.State.Mapping(State.AUTHORIZED, Event.LOGIN, on_already_logged_in),
             new Geary.State.Mapping(State.AUTHORIZED, Event.SEND_CMD, on_send_command),
             new Geary.State.Mapping(State.AUTHORIZED, Event.SELECT, on_select),
             new Geary.State.Mapping(State.AUTHORIZED, Event.CLOSE_MAILBOX, on_not_selected),
             new Geary.State.Mapping(State.AUTHORIZED, Event.LOGOUT, on_logout),
-            new Geary.State.Mapping(State.AUTHORIZED, Event.DISCONNECT, on_disconnect),
             new Geary.State.Mapping(State.AUTHORIZED, Event.RECV_STATUS, on_recv_status),
             new Geary.State.Mapping(State.AUTHORIZED, Event.RECV_COMPLETION, on_recv_status),
             new Geary.State.Mapping(State.AUTHORIZED, Event.SEND_ERROR, on_send_error),
             new Geary.State.Mapping(State.AUTHORIZED, Event.RECV_ERROR, on_recv_error),
 
             new Geary.State.Mapping(State.SELECTING, Event.CONNECT, on_already_connected),
+            new Geary.State.Mapping(State.SELECTING, Event.DISCONNECT, on_disconnect),
             new Geary.State.Mapping(State.SELECTING, Event.LOGIN, on_already_logged_in),
             new Geary.State.Mapping(State.SELECTING, Event.SEND_CMD, on_send_command),
             new Geary.State.Mapping(State.SELECTING, Event.SELECT, on_select),
             new Geary.State.Mapping(State.SELECTING, Event.CLOSE_MAILBOX, on_close_mailbox),
             new Geary.State.Mapping(State.SELECTING, Event.LOGOUT, on_logout),
-            new Geary.State.Mapping(State.SELECTING, Event.DISCONNECT, on_disconnect),
             new Geary.State.Mapping(State.SELECTING, Event.RECV_STATUS, on_recv_status),
-
             new Geary.State.Mapping(State.SELECTING, Event.RECV_COMPLETION, on_selecting_recv_completion),
             new Geary.State.Mapping(State.SELECTING, Event.SEND_ERROR, on_send_error),
             new Geary.State.Mapping(State.SELECTING, Event.RECV_ERROR, on_recv_error),
 
             new Geary.State.Mapping(State.SELECTED, Event.CONNECT, on_already_connected),
+            new Geary.State.Mapping(State.SELECTED, Event.DISCONNECT, on_disconnect),
             new Geary.State.Mapping(State.SELECTED, Event.LOGIN, on_already_logged_in),
             new Geary.State.Mapping(State.SELECTED, Event.SEND_CMD, on_send_command),
             new Geary.State.Mapping(State.SELECTED, Event.SELECT, on_select),
             new Geary.State.Mapping(State.SELECTED, Event.CLOSE_MAILBOX, on_close_mailbox),
             new Geary.State.Mapping(State.SELECTED, Event.LOGOUT, on_logout),
-            new Geary.State.Mapping(State.SELECTED, Event.DISCONNECT, on_disconnect),
             new Geary.State.Mapping(State.SELECTED, Event.RECV_STATUS, on_recv_status),
             new Geary.State.Mapping(State.SELECTED, Event.RECV_COMPLETION, on_recv_status),
             new Geary.State.Mapping(State.SELECTED, Event.SEND_ERROR, on_send_error),
             new Geary.State.Mapping(State.SELECTED, Event.RECV_ERROR, on_recv_error),
 
             new Geary.State.Mapping(State.CLOSING_MAILBOX, Event.CONNECT, on_already_connected),
+            new Geary.State.Mapping(State.CLOSING_MAILBOX, Event.DISCONNECT, on_disconnect),
             new Geary.State.Mapping(State.CLOSING_MAILBOX, Event.LOGIN, on_already_logged_in),
             new Geary.State.Mapping(State.CLOSING_MAILBOX, Event.SEND_CMD, on_send_command),
             new Geary.State.Mapping(State.CLOSING_MAILBOX, Event.SELECT, on_select),
             new Geary.State.Mapping(State.CLOSING_MAILBOX, Event.CLOSE_MAILBOX, on_not_selected),
             new Geary.State.Mapping(State.CLOSING_MAILBOX, Event.LOGOUT, on_logout),
-            new Geary.State.Mapping(State.CLOSING_MAILBOX, Event.DISCONNECT, on_disconnect),
             new Geary.State.Mapping(State.CLOSING_MAILBOX, Event.RECV_STATUS, on_recv_status),
             new Geary.State.Mapping(State.CLOSING_MAILBOX, Event.RECV_COMPLETION, 
on_closing_recv_completion),
             new Geary.State.Mapping(State.CLOSING_MAILBOX, Event.SEND_ERROR, on_send_error),
             new Geary.State.Mapping(State.CLOSING_MAILBOX, Event.RECV_ERROR, on_recv_error),
 
             new Geary.State.Mapping(State.LOGOUT, Event.CONNECT, on_already_connected),
+            new Geary.State.Mapping(State.LOGOUT, Event.DISCONNECT, on_disconnect),
             new Geary.State.Mapping(State.LOGOUT, Event.LOGIN, on_already_logged_in),
             new Geary.State.Mapping(State.LOGOUT, Event.SEND_CMD, on_late_command),
             new Geary.State.Mapping(State.LOGOUT, Event.SELECT, on_late_command),
             new Geary.State.Mapping(State.LOGOUT, Event.CLOSE_MAILBOX, on_late_command),
             new Geary.State.Mapping(State.LOGOUT, Event.LOGOUT, on_late_command),
-            new Geary.State.Mapping(State.LOGOUT, Event.DISCONNECT, on_disconnect),
-            new Geary.State.Mapping(State.LOGOUT, Event.DISCONNECTED, on_disconnected),
             new Geary.State.Mapping(State.LOGOUT, Event.RECV_STATUS, on_logging_out_recv_status),
             new Geary.State.Mapping(State.LOGOUT, Event.RECV_COMPLETION, on_logging_out_recv_completion),
             new Geary.State.Mapping(State.LOGOUT, Event.RECV_ERROR, on_recv_error),
             new Geary.State.Mapping(State.LOGOUT, Event.SEND_ERROR, on_send_error),
 
             new Geary.State.Mapping(State.CLOSED, Event.CONNECT, on_late_command),
+            new Geary.State.Mapping(State.CLOSED, Event.DISCONNECT, Geary.State.nop),
             new Geary.State.Mapping(State.CLOSED, Event.LOGIN, on_late_command),
             new Geary.State.Mapping(State.CLOSED, Event.SEND_CMD, on_late_command),
             new Geary.State.Mapping(State.CLOSED, Event.SELECT, on_late_command),
             new Geary.State.Mapping(State.CLOSED, Event.CLOSE_MAILBOX, on_late_command),
             new Geary.State.Mapping(State.CLOSED, Event.LOGOUT, on_late_command),
-            new Geary.State.Mapping(State.CLOSED, Event.DISCONNECT, Geary.State.nop),
-            new Geary.State.Mapping(State.CLOSED, Event.DISCONNECTED, on_disconnected),
             new Geary.State.Mapping(State.CLOSED, Event.RECV_STATUS, on_dropped_response),
             new Geary.State.Mapping(State.CLOSED, Event.RECV_COMPLETION, on_dropped_response),
             new Geary.State.Mapping(State.CLOSED, Event.SEND_ERROR, Geary.State.nop),
@@ -495,12 +483,63 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
         }
     }
 
-    public MailboxSpecifier? get_current_mailbox() {
-        return current_mailbox;
+    /**
+     * Returns the list of known personal mailbox namespaces.
+     *
+     * A personal namespace is a common prefix of a set of mailboxes
+     * that belong to the currently authenticated account. It may
+     * contain the account's Inbox and Sent mailboxes, for example.
+     *
+     * The list will be empty when the session is not in the
+     * authenticated or selected states, it will contain at least one
+     * after having successfully logged in.
+     *
+     * See [[https://tools.ietf.org/html/rfc2342|RFC 2342]] for more
+     * information.
+     */
+    public Gee.List<Namespace> get_personal_namespaces() {
+        return this.personal_namespaces.read_only_view;
     }
 
-    public bool is_current_mailbox_readonly() {
-        return current_mailbox_readonly;
+    /**
+     * Returns the list of known shared mailbox namespaces.
+     *
+     * A shared namespace is a common prefix of a set of mailboxes
+     * that are normally accessible by multiple accounts on the
+     * server, for example shared email mailboxes and NNTP news
+     * mailboxes.
+     *
+     * The list will be empty when the session is not in the
+     * authenticated or selected states, it will only be non-empty
+     * after having successfully logged in and if the server supports
+     * shared mailboxes.
+     *
+     * See [[https://tools.ietf.org/html/rfc2342|RFC 2342]] for more
+     * information.
+     */
+    public Gee.List<Namespace> get_shared_namespaces() {
+        return this.shared_namespaces.read_only_view;
+    }
+
+    /**
+     * Returns the list of known other-users mailbox namespaces.
+     *
+     * An other-user namespace is a common prefix of a set of
+     * mailboxes that are the personal mailboxes of other accounts on
+     * the server. These would not normally be accessible to the
+     * currently authenticated account unless the account has
+     * administration privileges.
+     *
+     * The list will be empty when the session is not in the
+     * authenticated or selected states, it will only be non-empty
+     * after having successfully logged in and if the server or
+     * account supports accessing other account's mailboxes.
+     *
+     * See [[https://tools.ietf.org/html/rfc2342|RFC 2342]] for more
+     * information.
+     */
+    public Gee.List<Namespace> get_other_users_namespaces() {
+        return this.user_namespaces.read_only_view;
     }
 
     /**
@@ -584,9 +623,7 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
      * Returns the current {@link ProtocolState} of the {@link ClientSession} and, if selected,
      * the current mailbox.
      */
-    public ProtocolState get_protocol_state(out MailboxSpecifier? current_mailbox) {
-        current_mailbox = null;
-
+    public ProtocolState get_protocol_state() {
         switch (fsm.get_state()) {
             case State.NOT_CONNECTED:
             case State.LOGOUT:
@@ -600,8 +637,6 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
                 return ProtocolState.AUTHORIZED;
 
             case State.SELECTED:
-                current_mailbox = this.current_mailbox;
-
                 return ProtocolState.SELECTED;
 
             case State.CONNECTING:
@@ -659,20 +694,19 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
     /**
      * Connect to the server.
      *
-     * This performs no transaction or session initiation with the server.  See {@link login_async}
-     * and {@link initiate_session_async} for next steps.
+     * This performs no transaction or session initiation with the
+     * server.  See {@link login_async} and {@link
+     * initiate_session_async} for next steps.
      *
-     * The signals {@link connected} or {@link session_denied} will be fired in the context of this
-     * call, depending on the results of the connection greeting from the server.  However,
-     * command should only be transmitted (login, initiate session, etc.) after this call has
-     * completed.
-     *
-     * If the connection fails (if this call throws an Error) the ClientSession will be disconnected,
-     * even if the error was from the server (that is, not a network problem).  The
-     * {@link ClientSession} should be discarded.
+     * If the connection fails (if this call throws an Error) the
+     * ClientSession will be disconnected, even if the error was from
+     * the server (that is, not a network problem).  The {@link
+     * ClientSession} should be discarded.
      */
-    public async void connect_async(GLib.Cancellable? cancellable)
-        throws GLib.Error {
+    public async void connect_async(
+        uint greeting_timeout_sec = DEFAULT_GREETING_TIMEOUT_SEC,
+        GLib.Cancellable? cancellable = null
+    ) throws GLib.Error {
         MachineParams params = new MachineParams(null);
         fsm.issue(Event.CONNECT, null, params);
 
@@ -688,17 +722,20 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
         // connect and let ClientConnection's signals drive the show
         try {
             yield cx.connect_async(cancellable);
-        } catch (Error err) {
+            fsm.issue(Event.CONNECTED);
+        } catch (GLib.Error err) {
             fsm.issue(Event.SEND_ERROR, null, null, err);
-
             throw err;
         }
 
         // set up timer to wait for greeting from server
-        Scheduler.Scheduled timeout = Scheduler.after_sec(GREETING_TIMEOUT_SEC, on_greeting_timeout);
+        Scheduler.Scheduled timeout = Scheduler.after_sec(
+            greeting_timeout_sec, on_greeting_timeout
+        );
 
-        // wait for the initial greeting or a timeout ... this prevents the caller from turning
-        // around and issuing a command while still in CONNECTING state
+        // wait for the initial greeting or a timeout ... this
+        // prevents the caller from turning around and issuing a
+        // command while still in CONNECTING state
         try {
             yield connect_waiter.wait_async(cancellable);
         } catch (GLib.IOError.CANCELLED err) {
@@ -739,8 +776,6 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
         assert(cx == null);
         cx = new ClientConnection(imap_endpoint);
         cx.set_logging_parent(this);
-        cx.connected.connect(on_network_connected);
-        cx.disconnected.connect(on_network_disconnected);
         cx.sent_command.connect(on_network_sent_command);
         cx.send_failure.connect(on_network_send_error);
         cx.received_status_response.connect(on_received_status_response);
@@ -748,9 +783,7 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
         cx.received_continuation_response.connect(on_received_continuation_response);
         cx.received_bytes.connect(on_received_bytes);
         cx.received_bad_response.connect(on_received_bad_response);
-        cx.received_eos.connect(on_received_eos);
         cx.receive_failure.connect(on_network_receive_failure);
-        cx.deserialize_failure.connect(on_network_receive_failure);
 
         assert(connect_waiter == null);
         connect_waiter = new Nonblocking.Semaphore();
@@ -760,14 +793,10 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
         return State.CONNECTING;
     }
 
-    // this is used internally to tear-down the ClientConnection object and unhook it from
-    // ClientSession
     private void drop_connection() {
         unschedule_keepalive();
 
         if (cx != null) {
-            cx.connected.disconnect(on_network_connected);
-            cx.disconnected.disconnect(on_network_disconnected);
             cx.sent_command.disconnect(on_network_sent_command);
             cx.send_failure.disconnect(on_network_send_error);
             cx.received_status_response.disconnect(on_received_status_response);
@@ -775,9 +804,7 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
             cx.received_continuation_response.disconnect(on_received_continuation_response);
             cx.received_bytes.disconnect(on_received_bytes);
             cx.received_bad_response.disconnect(on_received_bad_response);
-            cx.received_eos.connect(on_received_eos);
             cx.receive_failure.disconnect(on_network_receive_failure);
-            cx.deserialize_failure.disconnect(on_network_receive_failure);
 
             cx = null;
         }
@@ -791,19 +818,30 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
         return state;
     }
 
-    private uint on_disconnected(uint state,
-                                 uint event,
-                                 void *user = null,
-                                 GLib.Object? obj = null,
-                                 GLib.Error? err = null) {
+    private uint on_disconnect(uint state,
+                               uint event,
+                               void *user = null,
+                               GLib.Object? object = null,
+                               GLib.Error? err = null) {
         debug("Disconnected from %s", this.imap_endpoint.to_string());
+        MachineParams params = (MachineParams) object;
+        params.proceed = true;
         return State.CLOSED;
     }
 
     private uint on_connecting_recv_status(uint state, uint event, void *user, Object? object) {
         StatusResponse status_response = (StatusResponse) object;
 
-        // see on_connected() why signals and semaphore are delayed for this event
+        uint new_state = State.NOAUTH;
+        if (status_response.status != Status.OK) {
+            // Don't need to manually disconnect here, by setting
+            // connect_err here that will be done in connect_async
+            this.connect_err = new ImapError.UNAVAILABLE(
+                "Session denied: %s", status_response.get_text()
+            );
+            new_state = State.LOGOUT;
+        }
+
         try {
             connect_waiter.notify();
         } catch (Error err) {
@@ -813,25 +851,16 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
             );
         }
 
-        if (status_response.status == Status.OK) {
-            fsm.do_post_transition(() => { connected(); });
-
-            return State.NOAUTH;
-        }
-
-        fsm.do_post_transition(() => { session_denied(status_response.get_text()); });
+        return new_state;
+    }
 
+    private uint on_connecting_timeout(uint state, uint event) {
         // Don't need to manually disconnect here, by setting
         // connect_err here that will be done in connect_async
-        this.connect_err = new ImapError.UNAVAILABLE(
-            "Session denied: %s", status_response.get_text()
+        this.connect_err = new GLib.IOError.TIMED_OUT(
+            "Session greeting not sent"
         );
 
-        return State.LOGOUT;
-    }
-
-    private uint on_connecting_timeout(uint state, uint event) {
-        // wake up the waiting task in connect_async
         try {
             connect_waiter.notify();
         } catch (Error err) {
@@ -840,13 +869,6 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
             );
         }
 
-        // Don't need to manually disconnect here, by setting
-        // connect_err here that will be done in connect_async
-        this.connect_err = new IOError.TIMED_OUT(
-            "Session greeting not seen in %u seconds",
-            GREETING_TIMEOUT_SEC
-        );
-
         return State.LOGOUT;
     }
 
@@ -949,14 +971,14 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
                                              GLib.Cancellable? cancellable)
         throws GLib.Error {
         // If no capabilities available, get them now
-        if (capabilities.is_empty())
+        if (this.capabilities.is_empty()) {
             yield send_command_async(new CapabilityCommand(), cancellable);
+        }
 
-        // store them for comparison later
-        Imap.Capabilities caps = capabilities;
+        var last_capabilities = this.capabilities.revision;
 
         if (imap_endpoint.tls_method == TlsNegotiationMethod.START_TLS) {
-            if (!caps.has_capability(Capabilities.STARTTLS)) {
+            if (!this.capabilities.has_capability(Capabilities.STARTTLS)) {
                 throw new ImapError.NOT_SUPPORTED(
                     "STARTTLS unavailable for %s", to_string());
             }
@@ -977,51 +999,54 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
                     resp.status.to_string()
                 );
             }
+
+            if (last_capabilities == capabilities.revision) {
+                // Per RFC3501 §6.2.1, after TLS is established, all
+                // capabilities must be cleared and re-acquired to
+                // mitigate main-in-the-middle attacks. If the TLS
+                // command response did not update capabilities,
+                // explicitly do so now.
+                yield send_command_async(new CapabilityCommand(), cancellable);
+                last_capabilities = this.capabilities.revision;
+            }
         }
 
         // Login after STARTTLS
         yield login_async(credentials, cancellable);
 
         // if new capabilities not offered after login, get them now
-        if (caps.revision == capabilities.revision) {
+        if (last_capabilities == capabilities.revision) {
             yield send_command_async(new CapabilityCommand(), cancellable);
         }
 
-        // either way, new capabilities should be available
-        caps = capabilities;
-
-        Gee.List<ServerData> server_data = new Gee.ArrayList<ServerData>();
-        ulong data_id = this.server_data_received.connect((data) => { server_data.add(data); });
+        var list_results = new Gee.ArrayList<MailboxInformation>();
+        ulong list_id = this.list.connect(
+            (mailbox) => { list_results.add(mailbox); }
+        );
         try {
             // Determine what this connection calls the inbox
             Imap.StatusResponse response = yield send_command_async(
                 new ListCommand(MailboxSpecifier.inbox, false, null),
                 cancellable
             );
-            if (response.status == Status.OK && !server_data.is_empty) {
-                this.inbox = server_data[0].get_list();
+            if (response.status == Status.OK && !list_results.is_empty) {
+                this.inbox = list_results[0];
+                list_results.clear();
                 debug("Using INBOX: %s", this.inbox.to_string());
             } else {
                 throw new ImapError.INVALID("Unable to find INBOX");
             }
 
             // Try to determine what the connection's namespaces are
-            server_data.clear();
-            if (caps.has_capability(Capabilities.NAMESPACE)) {
+            if (this.capabilities.has_capability(Capabilities.NAMESPACE)) {
                 response = yield send_command_async(
                     new NamespaceCommand(),
                     cancellable
                 );
-                if (response.status == Status.OK && !server_data.is_empty) {
-                    NamespaceResponse ns = server_data[0].get_namespace();
-                    update_namespaces(ns.personal, this.personal_namespaces);
-                    update_namespaces(ns.user, this.user_namespaces);
-                    update_namespaces(ns.shared, this.shared_namespaces);
-                } else {
+                if (response.status != Status.OK) {
                     warning("NAMESPACE command failed");
                 }
             }
-            server_data.clear();
             if (!this.personal_namespaces.is_empty) {
                 debug(
                     "Default personal namespace: %s",
@@ -1045,8 +1070,8 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
                         new ListCommand(new MailboxSpecifier(prefix), false, null),
                         cancellable
                     );
-                    if (response.status == Status.OK && !server_data.is_empty) {
-                        MailboxInformation list = server_data[0].get_list();
+                    if (response.status == Status.OK && !list_results.is_empty) {
+                        MailboxInformation list = list_results[0];
                         delim = list.delim;
                     } else {
                         throw new ImapError.INVALID("Unable to determine personal namespace delimiter");
@@ -1058,21 +1083,7 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
                       this.personal_namespaces[0].to_string());
             }
         } finally {
-            disconnect(data_id);
-        }
-    }
-
-    private inline void update_namespaces(Gee.List<Namespace>? response, Gee.List<Namespace> list) {
-        if (response != null) {
-            foreach (Namespace ns in response) {
-                list.add(ns);
-                string prefix = ns.prefix;
-                string? delim = ns.delim;
-                if (delim != null && prefix.has_suffix(delim)) {
-                    prefix = prefix.substring(0, prefix.length - delim.length);
-                }
-                this.namespaces.set(prefix, ns);
-            }
+            disconnect(list_id);
         }
     }
 
@@ -1099,19 +1110,12 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
         if (!validate_state_change_cmd(completion_response))
             return state;
 
-        // Remember: only you can prevent firing signals inside state transition handlers
-        switch (completion_response.status) {
-            case Status.OK:
-                fsm.do_post_transition(() => { authorized(); });
-
-                return State.AUTHORIZED;
-
-            default:
-                debug("LOGIN failed: %s", completion_response.to_string());
-                fsm.do_post_transition((resp) => { login_failed((StatusResponse)resp); }, 
completion_response);
-
-                return State.NOAUTH;
+        uint new_state = State.AUTHORIZED;
+        if (completion_response.status != OK) {
+            debug("LOGIN failed: %s", completion_response.to_string());
+            new_state = State.NOAUTH;
         }
+        return new_state;
     }
 
     //
@@ -1120,18 +1124,24 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
     //
 
     /**
-     * If seconds is negative or zero, keepalives will be disabled.  (This is not recommended.)
+     * Enables sending keep-alive commands for the sesion.
+     *
+     * Although keepalives can be enabled at any time, if they're
+     * enabled and trigger sending a command prior to connection,
+     * error signals may be fired.
      *
-     * Although keepalives can be enabled at any time, if they're enabled and trigger sending
-     * a command prior to connection, error signals may be fired.
+     * If values are negative or zero, keepalives will be disabled.
+     * (This is not recommended.)
      */
     public void enable_keepalives(uint seconds_while_selected,
-        uint seconds_while_unselected, uint seconds_while_selected_with_idle) {
-        selected_keepalive_secs = seconds_while_selected;
-        selected_with_idle_keepalive_secs = seconds_while_selected_with_idle;
-        unselected_keepalive_secs = seconds_while_unselected;
-
-        // schedule one now, although will be rescheduled if traffic is received before it fires
+                                  uint seconds_while_unselected,
+                                  uint seconds_while_selected_with_idle) {
+        this.selected_keepalive_secs = seconds_while_selected;
+        this.selected_with_idle_keepalive_secs = seconds_while_selected_with_idle;
+        this.unselected_keepalive_secs = seconds_while_unselected;
+
+        // schedule one now, although will be rescheduled if traffic
+        // is received before it fires
         schedule_keepalive();
     }
 
@@ -1164,7 +1174,7 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
     public void enable_idle()
         throws GLib.Error {
         if (this.is_idle_supported) {
-            switch (get_protocol_state(null)) {
+            switch (get_protocol_state()) {
             case ProtocolState.AUTHORIZING:
             case ProtocolState.AUTHORIZED:
             case ProtocolState.SELECTED:
@@ -1185,7 +1195,7 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
         unschedule_keepalive();
 
         uint seconds;
-        switch (get_protocol_state(null)) {
+        switch (get_protocol_state()) {
             case ProtocolState.NOT_CONNECTED:
             case ProtocolState.CONNECTING:
                 return;
@@ -1341,8 +1351,7 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
                       status_response.to_string());
 
                 // nothing more we can do; drop connection and report disconnect to user
-                cx.disconnect_async.begin(null, on_bye_disconnect_completed);
-
+                this.do_disconnect.begin(DisconnectReason.REMOTE_CLOSE);
                 state = State.CLOSED;
             break;
 
@@ -1355,10 +1364,6 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
         return state;
     }
 
-    private void on_bye_disconnect_completed(Object? source, AsyncResult result) {
-        dispatch_disconnect_results(DisconnectReason.REMOTE_CLOSE, result);
-    }
-
     //
     // select/examine
     //
@@ -1415,45 +1420,34 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
     }
 
     private uint on_selecting_recv_completion(uint state, uint event, void *user, Object? object) {
+        uint new_state = state;
         StatusResponse completion_response = (StatusResponse) object;
 
-        Command? cmd;
-        if (!validate_state_change_cmd(completion_response, out cmd))
-            return state;
-
-        // get the mailbox from the command
-        MailboxSpecifier? mailbox = null;
-        if (cmd is SelectCommand) {
-            mailbox = ((SelectCommand) cmd).mailbox;
-            current_mailbox_readonly = false;
-        } else if (cmd is ExamineCommand) {
-            mailbox = ((ExamineCommand) cmd).mailbox;
-            current_mailbox_readonly = true;
-        }
-
-        // should only get to this point if cmd was SELECT or EXAMINE
-        assert(mailbox != null);
-
-        switch (completion_response.status) {
+        Command? cmd = null;
+        if (validate_state_change_cmd(completion_response, out cmd)) {
+            switch (completion_response.status) {
             case Status.OK:
-                // mailbox is SELECTED/EXAMINED, report change after completion of transition
-                MailboxSpecifier? old_mailbox = current_mailbox;
-                current_mailbox = mailbox;
-
-                if (old_mailbox != current_mailbox)
-                    fsm.do_post_transition(notify_select_completed, null, old_mailbox);
-
-                return State.SELECTED;
+                if (cmd is SelectCommand) {
+                    this.selected_mailbox = ((SelectCommand) cmd).mailbox;
+                    this.selected_readonly = false;
+                } else if (cmd is ExamineCommand) {
+                    this.selected_mailbox = ((ExamineCommand) cmd).mailbox;
+                    this.selected_readonly = true;
+                }
+                new_state = State.SELECTED;
+                break;
 
             default:
+                this.selected_mailbox = null;
+                this.selected_readonly = false;
+                new_state = State.AUTHORIZED;
                 warning("SELECT/EXAMINE failed: %s",
                         completion_response.to_string());
-                return State.AUTHORIZED;
+                break;
+            }
         }
-    }
 
-    private void notify_select_completed(void *user, Object? object) {
-        current_mailbox_changed((MailboxSpecifier) object, current_mailbox, current_mailbox_readonly);
+        return new_state;
     }
 
     //
@@ -1494,29 +1488,28 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
 
         switch (completion_response.status) {
             case Status.OK:
-                MailboxSpecifier? old_mailbox = current_mailbox;
-                current_mailbox = null;
-
-                if (old_mailbox != null)
-                    fsm.do_post_transition(notify_mailbox_closed, null, old_mailbox);
-
+                this.selected_mailbox = null;
+                this.selected_readonly = false;
                 return State.AUTHORIZED;
 
             default:
                 warning("CLOSE failed: %s", completion_response.to_string());
-
                 return State.SELECTED;
         }
     }
 
-    private void notify_mailbox_closed(void *user, Object? object) {
-        current_mailbox_changed((MailboxSpecifier) object, null, false);
-    }
-
     //
     // logout
     //
 
+    /**
+     * Sends a logout command.
+     *
+     * If the connection is still available and the server still
+     * responding, this will result in the connection being closed
+     * gracefully. Thus unless an error occurs, {@link
+     * disconnect_async} would not need to be called afterwards.
+     */
     public async void logout_async(GLib.Cancellable? cancellable)
         throws GLib.Error {
         LogoutCommand cmd = new LogoutCommand();
@@ -1529,14 +1522,7 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
 
         if (params.proceed) {
             yield command_transaction_async(cmd, cancellable);
-            logged_out();
-            this.cx.disconnect_async.begin(
-                cancellable, (obj, res) => {
-                    dispatch_disconnect_results(
-                        DisconnectReason.LOCAL_CLOSE, res
-                    );
-                }
-            );
+            yield do_disconnect(DisconnectReason.LOCAL_CLOSE);
         }
     }
 
@@ -1581,10 +1567,14 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
     private uint on_logging_out_recv_completion(uint state, uint event, void *user, Object? object) {
         StatusResponse completion_response = (StatusResponse) object;
 
-        if (!validate_state_change_cmd(completion_response))
-            return state;
+        uint new_state = state;
+        if (validate_state_change_cmd(completion_response)) {
+            new_state = State.CLOSED;
 
-        return State.CLOSED;
+            // Namespaces are only valid in AUTH and SELECTED states
+            clear_namespaces();
+        }
+        return new_state;
     }
 
     //
@@ -1626,17 +1616,17 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
 
     /** {@inheritDoc} */
     public Logging.State to_logging_state() {
-        return (this.current_mailbox == null)
+        return (this.selected_mailbox == null)
             ? new Logging.State(
                 this,
                 this.fsm.get_state_string(fsm.get_state())
             )
             : new Logging.State(
                 this,
-                "%s:%s %s",
+                "%s:%s selected %s",
                 this.fsm.get_state_string(fsm.get_state()),
-                this.current_mailbox.to_string(),
-                this.current_mailbox_readonly ? "RO" : "RW"
+                this.selected_mailbox.to_string(),
+                this.selected_readonly ? "RO" : "RW"
             );
     }
 
@@ -1645,12 +1635,15 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
         this._logging_parent = parent;
     }
 
-    private uint on_disconnect(uint state, uint event, void *user, Object? object) {
-        MachineParams params = (MachineParams) object;
-
-        params.proceed = true;
+    private async void do_disconnect(DisconnectReason reason) {
+        try {
+            yield this.cx.disconnect_async();
+        } catch (GLib.Error err) {
+            debug("IMAP disconnect failed: %s", err.message);
+        }
 
-        return State.CLOSED;
+        drop_connection();
+        disconnected(reason);
     }
 
     //
@@ -1667,56 +1660,37 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
                                                void *user,
                                                GLib.Object? object,
                                                GLib.Error? err) {
-        debug("Connecting send/recv error, dropping client connection: %s",
-              err != null ? err.message : "EOS");
+        debug(
+            "Connecting send/recv error, dropping client connection: %s",
+            err != null ? err.message : "(no error)"
+        );
         fsm.do_post_transition(() => { drop_connection(); });
         return State.CLOSED;
     }
 
     private uint on_send_error(uint state, uint event, void *user, Object? object, Error? err) {
-        assert(err != null);
-
-        if (err is IOError.CANCELLED)
+        if (err is IOError.CANCELLED) {
             return state;
+        }
 
         debug("Send error, disconnecting: %s", err.message);
-
-        cx.disconnect_async.begin(null, on_fire_send_error_signal);
-
+        this.do_disconnect.begin(DisconnectReason.LOCAL_ERROR);
         return State.CLOSED;
     }
 
-    private void on_fire_send_error_signal(Object? object, AsyncResult result) {
-        dispatch_disconnect_results(DisconnectReason.LOCAL_ERROR, result);
-    }
-
     private uint on_recv_error(uint state,
                                uint event,
                                void *user,
                                GLib.Object? object,
                                GLib.Error? err) {
-        debug("Receive error, disconnecting: %s",
-              (err != null) ? err.message : "EOS"
+        debug(
+            "Receive error, disconnecting: %s",
+            (err != null) ? err.message : "(no error)"
         );
-        cx.disconnect_async.begin(null, on_fire_recv_error_signal);
+        this.do_disconnect.begin(DisconnectReason.REMOTE_ERROR);
         return State.CLOSED;
     }
 
-    private void on_fire_recv_error_signal(Object? object, AsyncResult result) {
-        dispatch_disconnect_results(DisconnectReason.REMOTE_ERROR, result);
-    }
-
-    private void dispatch_disconnect_results(DisconnectReason reason, AsyncResult result) {
-        try {
-            cx.disconnect_async.end(result);
-        } catch (Error err) {
-            debug("Send/recv disconnect failed: %s", err.message);
-        }
-
-        drop_connection();
-        disconnected(reason);
-    }
-
     // This handles the situation where the user submits a command before the connection has been
     // established
     private uint on_early_command(uint state, uint event, void *user, Object? object) {
@@ -1807,14 +1781,6 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
     // network connection event handlers
     //
 
-    private void on_network_connected() {
-        fsm.issue(Event.CONNECTED);
-    }
-
-    private void on_network_disconnected() {
-        fsm.issue(Event.DISCONNECTED);
-    }
-
     private void on_network_sent_command(Command cmd) {
         // resechedule keepalive
         schedule_keepalive();
@@ -1844,12 +1810,14 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
             if (response_code != null) {
                 try {
                     if (response_code.get_response_code_type().is_value(ResponseCodeType.CAPABILITY)) {
-                        capabilities = response_code.get_capabilities(ref next_capabilities_revision);
-                        debug("%s %s",
-                              status_response.status.to_string(),
-                              capabilities.to_string());
-
-                        capability(capabilities);
+                        this.capabilities = response_code.get_capabilities(
+                            this.capabilities.revision + 1
+                        );
+                        debug(
+                            "%s set capabilities to: %s",
+                            status_response.status.to_string(),
+                            this.capabilities.to_string()
+                        );
                     }
                 } catch (GLib.Error err) {
                     warning(
@@ -1876,12 +1844,14 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
             case ServerDataType.CAPABILITY:
                 // update ClientSession capabilities before firing signal, so external signal
                 // handlers that refer back to property aren't surprised
-                capabilities = server_data.get_capabilities(ref next_capabilities_revision);
-                debug("%s %s",
-                      server_data.server_data_type.to_string(),
-                      capabilities.to_string());
-
-                capability(capabilities);
+                this.capabilities = server_data.get_capabilities(
+                    this.capabilities.revision + 1
+                );
+                debug(
+                    "%s set capabilities to: %s",
+                    server_data.server_data_type.to_string(),
+                    this.capabilities.to_string()
+                );
             break;
 
             case ServerDataType.EXISTS:
@@ -1918,7 +1888,14 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
             break;
 
             case ServerDataType.NAMESPACE:
-                namespace(server_data.get_namespace());
+                // Clear namespaces before updating them, since if
+                // they have changed the new ones take full
+                // precedence.
+                clear_namespaces();
+                NamespaceResponse ns = server_data.get_namespace();
+                update_namespaces(ns.personal, this.personal_namespaces);
+                update_namespaces(ns.shared, this.shared_namespaces);
+                update_namespaces(ns.user, this.user_namespaces);
             break;
 
             // TODO: LSUB
@@ -1929,8 +1906,28 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
                       server_data.to_string());
             break;
         }
+    }
 
-        server_data_received(server_data);
+    private void clear_namespaces() {
+        this.namespaces.clear();
+        this.personal_namespaces.clear();
+        this.shared_namespaces.clear();
+        this.user_namespaces.clear();
+    }
+
+    private void update_namespaces(Gee.List<Namespace>? response,
+                                   Gee.List<Namespace> list) {
+        if (response != null) {
+            foreach (Namespace ns in response) {
+                list.add(ns);
+                string prefix = ns.prefix;
+                string? delim = ns.delim;
+                if (delim != null && prefix.has_suffix(delim)) {
+                    prefix = prefix.substring(0, prefix.length - delim.length);
+                }
+                this.namespaces.set(prefix, ns);
+            }
+        }
     }
 
     private void on_received_server_data(ServerData server_data) {
@@ -1968,11 +1965,7 @@ public class Geary.Imap.ClientSession : BaseObject, Logging.Source {
         fsm.issue(Event.RECV_ERROR, null, null, err);
     }
 
-    private void on_received_eos(ClientConnection cx) {
-        fsm.issue(Event.RECV_ERROR, null, null, null);
-    }
-
-    private void on_network_receive_failure(Error err) {
+    private void on_network_receive_failure(GLib.Error err) {
         fsm.issue(Event.RECV_ERROR, null, null, err);
     }
 
diff --git a/src/engine/imap/transport/imap-deserializer.vala 
b/src/engine/imap/transport/imap-deserializer.vala
index 6959b09d..2ad9120b 100644
--- a/src/engine/imap/transport/imap-deserializer.vala
+++ b/src/engine/imap/transport/imap-deserializer.vala
@@ -1,7 +1,9 @@
-/* Copyright 2016 Software Freedom Conservancy Inc.
+/*
+ * Copyright 2016 Software Freedom Conservancy Inc.
+ * Copyright 2019 Michael Gratton <mike vee net>
  *
  * This software is licensed under the GNU Lesser General Public License
- * (version 2.1 or later).  See the COPYING file in this distribution.
+ * (version 2.1 or later). See the COPYING file in this distribution.
  */
 
 /**
@@ -72,7 +74,7 @@ public class Geary.Imap.Deserializer : BaseObject {
         state_to_string, event_to_string);
 
     private string identifier;
-    private DataInputStream dins;
+    private DataInputStream input;
     private Geary.State.Machine fsm;
 
     private ListParameter context;
@@ -81,7 +83,6 @@ public class Geary.Imap.Deserializer : BaseObject {
 
     private Cancellable? cancellable = null;
     private Nonblocking.Semaphore closed_semaphore = new Nonblocking.Semaphore();
-    private Geary.Stream.MidstreamConverter midstream = new Geary.Stream.MidstreamConverter("Deserializer");
     private StringBuilder? current_string = null;
     private size_t literal_length_remaining = 0;
     private Geary.Memory.GrowableBuffer? block_buffer = null;
@@ -117,36 +118,36 @@ public class Geary.Imap.Deserializer : BaseObject {
     public signal void bytes_received(size_t bytes);
 
     /**
-     * Fired when the underlying InputStream is closed, whether due to normal EOS or input error.
+     * Fired when a syntax error has occurred.
      *
-     * @see receive_failure
+     * This generally means the data looks like garbage and further
+     * deserialization is unlikely or impossible.
      */
-    public signal void eos();
+    public signal void deserialize_failure();
 
     /**
-     * Fired when a syntax error has occurred.
+     * Fired when an Error is trapped on the input stream.
      *
-     * This generally means the data looks like garbage and further deserialization is unlikely
-     * or impossible.
+     * This is nonrecoverable and means the stream should be closed
+     * and this Deserializer destroyed.
      */
-    public signal void deserialize_failure();
+    public signal void receive_failure(GLib.Error err);
 
     /**
-     * Fired when an Error is trapped on the input stream.
+     * Fired when the underlying InputStream is closed.
      *
-     * This is nonrecoverable and means the stream should be closed and this Deserializer destroyed.
+     * This is nonrecoverable and means the stream should be closed
+     * and this Deserializer destroyed.
      */
-    public signal void receive_failure(Error err);
+    public signal void end_of_stream();
 
 
-    public Deserializer(string identifier, InputStream ins) {
+    public Deserializer(string identifier, GLib.InputStream input) {
         this.identifier = identifier;
 
-        ConverterInputStream cins = new ConverterInputStream(ins, midstream);
-        cins.set_close_base_stream(false);
-        dins = new DataInputStream(cins);
-        dins.set_newline_type(DataStreamNewlineType.CR_LF);
-        dins.set_close_base_stream(false);
+        this.input = new GLib.DataInputStream(input);
+        this.input.set_close_base_stream(false);
+        this.input.set_newline_type(CR_LF);
 
         Geary.State.Mapping[] mappings = {
             new Geary.State.Mapping(State.TAG, Event.CHAR, on_tag_char),
@@ -210,15 +211,6 @@ public class Geary.Imap.Deserializer : BaseObject {
         reset_params();
     }
 
-    /**
-     * Install a custom Converter into the input stream.
-     *
-     * Can be used for decompression, decryption, and so on.
-     */
-    public bool install_converter(Converter converter) {
-        return midstream.install(converter);
-    }
-
     /**
      * Begin deserializing IMAP responses from the input stream.
      *
@@ -252,6 +244,7 @@ public class Geary.Imap.Deserializer : BaseObject {
 
         // wait for outstanding I/O to exit
         yield closed_semaphore.wait_async();
+        yield this.input.close_async(GLib.Priority.DEFAULT, null);
         Logging.debug(Logging.Flag.DESERIALIZER, "[%s] Deserializer closed", to_string());
     }
 
@@ -279,7 +272,9 @@ public class Geary.Imap.Deserializer : BaseObject {
     private void next_deserialize_step() {
         switch (get_mode()) {
             case Mode.LINE:
-                dins.read_line_async.begin(ins_priority, cancellable, on_read_line);
+                this.input.read_line_async.begin(
+                    ins_priority, cancellable, on_read_line
+                );
             break;
 
             case Mode.BLOCK:
@@ -293,7 +288,9 @@ public class Geary.Imap.Deserializer : BaseObject {
                 current_buffer = block_buffer.allocate(
                     size_t.min(MAX_BLOCK_READ_SIZE, literal_length_remaining));
 
-                dins.read_async.begin(current_buffer, ins_priority, cancellable, on_read_block);
+                this.input.read_async.begin(
+                    current_buffer, ins_priority, cancellable, on_read_block
+                );
             break;
 
             case Mode.FAILED:
@@ -309,7 +306,9 @@ public class Geary.Imap.Deserializer : BaseObject {
     private void on_read_line(Object? source, AsyncResult result) {
         try {
             size_t bytes_read;
-            string? line = dins.read_line_async.end(result, out bytes_read);
+            string? line = this.input.read_line_async.end(
+                result, out bytes_read
+            );
             if (line == null) {
                 Logging.debug(Logging.Flag.DESERIALIZER, "[%s] line EOS", to_string());
 
@@ -333,7 +332,7 @@ public class Geary.Imap.Deserializer : BaseObject {
         try {
             // Zero-byte literals are legal (see note in next_deserialize_step()), so EOS only
             // happens when actually pulling data
-            size_t bytes_read = dins.read_async.end(result);
+            size_t bytes_read = this.input.read_async.end(result);
             if (bytes_read == 0 && literal_length_remaining > 0) {
                 Logging.debug(Logging.Flag.DESERIALIZER, "[%s] block EOS", to_string());
 
@@ -816,8 +815,8 @@ public class Geary.Imap.Deserializer : BaseObject {
         flush_params();
 
         // always signal as closed and notify subscribers
-        closed_semaphore.blind_notify();
-        eos();
+        this.closed_semaphore.blind_notify();
+        end_of_stream();
 
         return State.CLOSED;
     }
@@ -833,9 +832,7 @@ public class Geary.Imap.Deserializer : BaseObject {
         }
 
         // always signal as closed and notify
-        closed_semaphore.blind_notify();
-        eos();
-
+        this.closed_semaphore.blind_notify();
         return State.CLOSED;
     }
 
diff --git a/src/engine/imap/transport/imap-serializer.vala b/src/engine/imap/transport/imap-serializer.vala
index 549391fc..bbe52e2a 100644
--- a/src/engine/imap/transport/imap-serializer.vala
+++ b/src/engine/imap/transport/imap-serializer.vala
@@ -1,32 +1,35 @@
 /*
- * Copyright 2016 Software Freedom Conservancy Inc.
- * Copyright 2018 Michael Gratton <mike vee net>
+ * Copyright © 2016 Software Freedom Conservancy Inc.
+ * Copyright © 2018, 2020 Michael Gratton <mike vee net>
  *
  * This software is licensed under the GNU Lesser General Public License
  * (version 2.1 or later).  See the COPYING file in this distribution.
  */
 
 /**
- * Writes IMAP protocol strings to a supplied output stream.
+ * Writes IMAP protocol strings to the supplied output stream.
  *
- * This class uses a {@link GLib.DataOutputStream} for writing strings
- * to the given stream. Since that does not support asynchronous
- * writes, it is highly desirable that the stream passed to this class
- * is a {@link GLib.BufferedOutputStream}, or some other type that
- * uses a memory buffer large enough to write a typical command
- * completely without causing disk or network I/O.
+ * Since most IMAP commands are small (with the exception of literal
+ * data) this class writes directly, synchronously to the given
+ * stream. Thus it is highly desirable that the stream passed to the
+ * constructor is buffered, either a {@link
+ * GLib.BufferedOutputStream}, or some other type that uses a memory
+ * buffer large enough to write a typical command completely without
+ * causing disk or network I/O.
  *
  * @see Deserializer
  */
 public class Geary.Imap.Serializer : BaseObject {
 
-    private string identifier;
-    private GLib.DataOutputStream output;
 
-    public Serializer(string identifier, GLib.OutputStream output) {
-        this.identifier = identifier;
-        this.output = new GLib.DataOutputStream(output);
-        this.output.set_close_base_stream(false);
+    private const string EOL = "\r\n";
+    private const string SPACE = " ";
+
+    private GLib.OutputStream output;
+
+
+    public Serializer(GLib.OutputStream output) {
+        this.output = output;
     }
 
     /**
@@ -39,7 +42,7 @@ public class Geary.Imap.Serializer : BaseObject {
     public void push_unquoted_string(string str,
                                      GLib.Cancellable? cancellable = null)
         throws GLib.Error {
-        this.output.put_string(str, cancellable);
+        this.output.write_all(str.data, null, cancellable);
     }
 
     /**
@@ -52,17 +55,19 @@ public class Geary.Imap.Serializer : BaseObject {
     public void push_quoted_string(string str,
                                    GLib.Cancellable? cancellable = null)
         throws GLib.Error {
-        this.output.put_byte('"');
+        StringBuilder buf = new StringBuilder.sized(str.length + 2);
+        buf.append_c('"');
         int index = 0;
         char ch = str[index];
         while (ch != String.EOS) {
             if (ch == '"' || ch == '\\') {
-                this.output.put_byte('\\');
+                buf.append_c('\\');
             }
-            this.output.put_byte(ch);
+            buf.append_c(ch);
             ch = str[++index];
         }
-        this.output.put_byte('"');
+        buf.append_c('"');
+        this.output.write_all(buf.data, null, cancellable);
     }
 
     /**
@@ -73,15 +78,17 @@ public class Geary.Imap.Serializer : BaseObject {
      */
     public void push_ascii(char ch, GLib.Cancellable? cancellable = null)
         throws GLib.Error {
-        this.output.put_byte(ch, cancellable);
+        // allocate array on the stack to avoid mem alloc overhead
+        uint8 buf[1] = { ch };
+        this.output.write_all(buf, null, cancellable);
     }
 
     /**
-     * Writes a single ASCII space character.
+     * Writes a ASCII space character.
      */
     public void push_space(GLib.Cancellable? cancellable = null)
         throws GLib.Error {
-        this.output.put_byte(' ', cancellable);
+        this.output.write_all(SPACE.data, null, cancellable);
     }
 
     /**
@@ -89,7 +96,7 @@ public class Geary.Imap.Serializer : BaseObject {
      */
     public void push_nil(GLib.Cancellable? cancellable = null)
         throws GLib.Error {
-        this.output.put_string(NilParameter.VALUE, cancellable);
+        this.output.write_all(NilParameter.VALUE.data, null, cancellable);
     }
 
     /**
@@ -97,7 +104,7 @@ public class Geary.Imap.Serializer : BaseObject {
      */
     public void push_eol(GLib.Cancellable? cancellable = null)
         throws GLib.Error {
-        this.output.put_string("\r\n", cancellable);
+        this.output.write_all(EOL.data, null, cancellable);
     }
 
     /**
@@ -121,14 +128,15 @@ public class Geary.Imap.Serializer : BaseObject {
      */
     public async void flush_stream(GLib.Cancellable? cancellable = null)
         throws GLib.Error {
-        yield this.output.flush_async(Priority.DEFAULT, cancellable);
+        yield this.output.flush_async(GLib.Priority.DEFAULT, cancellable);
     }
 
     /**
-     * Returns a string representation for debugging.
+     * Closes the stream, ensuring a command has been sent.
      */
-    public string to_string() {
-        return "ser:%s".printf(identifier);
+    public async void close_stream(GLib.Cancellable? cancellable)
+        throws GLib.IOError {
+        yield this.output.close_async(GLib.Priority.DEFAULT, cancellable);
     }
 
 }
diff --git a/src/engine/meson.build b/src/engine/meson.build
index 23b5fa48..0d6cbe71 100644
--- a/src/engine/meson.build
+++ b/src/engine/meson.build
@@ -87,6 +87,7 @@ geary_engine_vala_sources = files(
   'imap/imap.vala',
   'imap/imap-error.vala',
   'imap/api/imap-account-session.vala',
+  'imap/api/imap-capabilities.vala',
   'imap/api/imap-client-service.vala',
   'imap/api/imap-email-flags.vala',
   'imap/api/imap-email-properties.vala',
@@ -150,7 +151,6 @@ geary_engine_vala_sources = files(
   'imap/parameter/imap-root-parameters.vala',
   'imap/parameter/imap-string-parameter.vala',
   'imap/parameter/imap-unquoted-string-parameter.vala',
-  'imap/response/imap-capabilities.vala',
   'imap/response/imap-continuation-response.vala',
   'imap/response/imap-fetch-data-decoder.vala',
   'imap/response/imap-fetched-data.vala',
diff --git a/src/engine/util/util-generic-capabilities.vala b/src/engine/util/util-generic-capabilities.vala
index b4b48ae4..a312786b 100644
--- a/src/engine/util/util-generic-capabilities.vala
+++ b/src/engine/util/util-generic-capabilities.vala
@@ -32,39 +32,6 @@ public class Geary.GenericCapabilities : BaseObject {
         return (map.size == 0);
     }
 
-    public bool parse_and_add_capability(string text) {
-        string[] name_values = text.split(name_separator, 2);
-        switch (name_values.length) {
-            case 1:
-                add_capability(name_values[0]);
-            break;
-
-            case 2:
-                if (value_separator == null) {
-                    add_capability(name_values[0], name_values[1]);
-                } else {
-                    // break up second token for multiple values
-                    string[] values = name_values[1].split(value_separator);
-                    if (values.length <= 1) {
-                        add_capability(name_values[0], name_values[1]);
-                    } else {
-                        foreach (string value in values)
-                            add_capability(name_values[0], value);
-                    }
-                }
-            break;
-
-            default:
-                return false;
-        }
-
-        return true;
-    }
-
-    public void add_capability(string name, string? setting = null) {
-        map.set(name, String.is_empty(setting) ? null : setting);
-    }
-
     /**
      * Returns true only if the capability was named as available by the server.
      */
@@ -103,13 +70,6 @@ public class Geary.GenericCapabilities : BaseObject {
         return (names.size > 0) ? names : null;
     }
 
-    private void append(StringBuilder builder, string text) {
-        if (!String.is_empty(builder.str))
-            builder.append(String.is_empty(value_separator) ? " " : value_separator);
-
-        builder.append(text);
-    }
-
     public virtual string to_string() {
         Gee.Set<string>? names = get_all_names();
         if (names == null || names.size == 0)
@@ -132,5 +92,45 @@ public class Geary.GenericCapabilities : BaseObject {
 
         return builder.str;
     }
-}
 
+    private inline void append(StringBuilder builder, string text) {
+        if (!String.is_empty(builder.str))
+            builder.append(String.is_empty(value_separator) ? " " : value_separator);
+
+        builder.append(text);
+    }
+
+    protected bool parse_and_add_capability(string text) {
+        string[] name_values = text.split(name_separator, 2);
+        switch (name_values.length) {
+            case 1:
+                add_capability(name_values[0]);
+            break;
+
+            case 2:
+                if (value_separator == null) {
+                    add_capability(name_values[0], name_values[1]);
+                } else {
+                    // break up second token for multiple values
+                    string[] values = name_values[1].split(value_separator);
+                    if (values.length <= 1) {
+                        add_capability(name_values[0], name_values[1]);
+                    } else {
+                        foreach (string value in values)
+                            add_capability(name_values[0], value);
+                    }
+                }
+            break;
+
+            default:
+                return false;
+        }
+
+        return true;
+    }
+
+    private inline void add_capability(string name, string? setting = null) {
+        this.map.set(name, String.is_empty(setting) ? null : setting);
+    }
+
+}
diff --git a/src/engine/util/util-stream.vala b/src/engine/util/util-stream.vala
index 2cffb05a..18df50fb 100644
--- a/src/engine/util/util-stream.vala
+++ b/src/engine/util/util-stream.vala
@@ -41,80 +41,6 @@ namespace Geary.Stream {
             yield write_all_async(outs, new Memory.StringBuffer(str), cancellable);
     }
 
-
-    public class MidstreamConverter : BaseObject, Converter {
-        public uint64 total_bytes_read { get; private set; default = 0; }
-        public uint64 total_bytes_written { get; private set; default = 0; }
-        public uint64 converted_bytes_read { get; private set; default = 0; }
-        public uint64 converted_bytes_written { get; private set; default = 0; }
-
-        public bool log_performance { get; set; default = false; }
-
-        private string name;
-        private Converter? converter = null;
-
-        public MidstreamConverter(string name) {
-            this.name = name;
-        }
-
-        public bool install(Converter converter) {
-            if (this.converter != null)
-                return false;
-
-            this.converter = converter;
-
-            return true;
-        }
-
-        public ConverterResult convert(uint8[] inbuf, uint8[] outbuf, ConverterFlags flags,
-                                       out size_t bytes_read, out size_t bytes_written) throws Error {
-            if (converter != null) {
-                ConverterResult result = converter.convert(inbuf, outbuf, flags, out bytes_read, out 
bytes_written);
-
-                total_bytes_read += bytes_read;
-                total_bytes_written += bytes_written;
-
-                converted_bytes_read += bytes_read;
-                converted_bytes_written += bytes_written;
-
-                if (log_performance && (bytes_read > 0 || bytes_written > 0)) {
-                    double pct = (converted_bytes_read > converted_bytes_written)
-                    ? (double) converted_bytes_written / (double) converted_bytes_read
-                    : (double) converted_bytes_read / (double) converted_bytes_written;
-                    debug("%s read/written: %s/%s (%lld%%)", name, converted_bytes_read.to_string(),
-                          converted_bytes_written.to_string(), (long) (pct * 100.0));
-                }
-
-                return result;
-            }
-
-            // passthrough
-            size_t copied = size_t.min(inbuf.length, outbuf.length);
-            if (copied > 0)
-                GLib.Memory.copy(outbuf, inbuf, copied);
-
-            bytes_read = copied;
-            bytes_written = copied;
-
-            total_bytes_read += copied;
-            total_bytes_written += copied;
-
-            if ((flags & ConverterFlags.FLUSH) != 0)
-                return ConverterResult.FLUSHED;
-
-            if ((flags & ConverterFlags.INPUT_AT_END) != 0)
-                return ConverterResult.FINISHED;
-
-            return ConverterResult.CONVERTED;
-        }
-
-        public void reset() {
-            if (converter != null)
-                converter.reset();
-        }
-    }
-
-
     /**
      * Adaptor from a GMime stream to a GLib OutputStream.
      */
diff --git a/test/engine/imap-db/imap-db-account-test.vala b/test/engine/imap-db/imap-db-account-test.vala
index 3d1e90ab..61b62506 100644
--- a/test/engine/imap-db/imap-db-account-test.vala
+++ b/test/engine/imap-db/imap-db-account-test.vala
@@ -84,7 +84,7 @@ class Geary.ImapDB.AccountTest : TestCase {
                     new Imap.UIDValidity(7),
                     6 //unseen
                 ),
-                new Imap.Capabilities(1)
+                new Imap.Capabilities.empty(0)
             )
         );
 
@@ -123,7 +123,7 @@ class Geary.ImapDB.AccountTest : TestCase {
                     new Imap.UIDValidity(7),
                     6 //unseen
                 ),
-                new Imap.Capabilities(1)
+                new Imap.Capabilities.empty(0)
             )
         );
 
diff --git a/test/engine/imap/transport/imap-client-connection-test.vala 
b/test/engine/imap/transport/imap-client-connection-test.vala
new file mode 100644
index 00000000..c98f0dc5
--- /dev/null
+++ b/test/engine/imap/transport/imap-client-connection-test.vala
@@ -0,0 +1,160 @@
+/*
+ * Copyright 2019 Michael Gratton <mike vee net>
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+class Geary.Imap.ClientConnectionTest : TestCase {
+
+
+    private class TestCommand : Command {
+
+        public TestCommand() {
+            base("TEST");
+        }
+
+    }
+
+    private TestServer? server = null;
+
+
+    public ClientConnectionTest() {
+        base("Geary.Imap.ClientConnectionTest");
+        add_test("connect_disconnect", connect_disconnect);
+        if (GLib.Test.slow()) {
+            add_test("idle", idle);
+            add_test("command_timeout", command_timeout);
+        }
+    }
+
+    protected override void set_up() throws GLib.Error {
+        this.server = new TestServer();
+    }
+
+    protected override void tear_down() {
+        this.server.stop();
+        this.server = null;
+    }
+
+    public void connect_disconnect() throws GLib.Error {
+        var test_article = new ClientConnection(new_endpoint());
+
+        test_article.connect_async.begin(null, this.async_complete_full);
+        test_article.connect_async.end(async_result());
+
+        assert_non_null(test_article.get_remote_address());
+        assert_non_null(test_article.get_local_address());
+
+        test_article.disconnect_async.begin(null, this.async_complete_full);
+        test_article.disconnect_async.end(async_result());
+
+        assert_null(test_article.get_remote_address());
+        assert_null(test_article.get_local_address());
+
+        TestServer.Result result = this.server.wait_for_script(this.main_loop);
+        assert(result.succeeded);
+    }
+
+    public void idle() throws GLib.Error {
+        this.server.add_script_line(RECEIVE_LINE, "a001 IDLE");
+        this.server.add_script_line(SEND_LINE, "+ idling");
+        this.server.add_script_line(RECEIVE_LINE, "DONE");
+        this.server.add_script_line(SEND_LINE, "a001 OK Completed");
+        this.server.add_script_line(RECEIVE_LINE, "a002 TEST");
+        this.server.add_script_line(SEND_LINE, "a002 OK Looks good");
+        this.server.add_script_line(WAIT_FOR_DISCONNECT, "");
+
+        const int COMMAND_TIMEOUT = 1;
+        const int IDLE_TIMEOUT = 1;
+
+        var test_article = new ClientConnection(
+            new_endpoint(), COMMAND_TIMEOUT, IDLE_TIMEOUT
+        );
+        test_article.connect_async.begin(null, this.async_complete_full);
+        test_article.connect_async.end(async_result());
+
+        assert_false(test_article.is_in_idle(), "Initial idle state");
+        test_article.enable_idle_when_quiet(true);
+        assert_false(test_article.is_in_idle(), "Post-enabled idle state");
+
+        // Wait for idle to kick in
+        GLib.Timer timer = new GLib.Timer();
+        timer.start();
+        while (!test_article.is_in_idle() &&
+               timer.elapsed() < IDLE_TIMEOUT * 2) {
+            this.main_loop.iteration(false);
+        }
+
+        assert_true(test_article.is_in_idle(), "Entered idle");
+
+        // Ensure idle outlives command timeout
+        timer.start();
+        while (timer.elapsed() < COMMAND_TIMEOUT * 2) {
+            this.main_loop.iteration(false);
+        }
+
+        assert_true(test_article.is_in_idle(), "Post idle command timeout");
+
+        var command = new TestCommand();
+        test_article.send_command(command);
+        command.wait_until_complete.begin(null, this.async_complete_full);
+        command.wait_until_complete.end(async_result());
+
+        assert_false(test_article.is_in_idle(), "Post test command");
+
+        test_article.disconnect_async.begin(null, this.async_complete_full);
+        test_article.disconnect_async.end(async_result());
+
+        TestServer.Result result = this.server.wait_for_script(this.main_loop);
+        assert(result.succeeded);
+    }
+
+    public void command_timeout() throws GLib.Error {
+        this.server.add_script_line(
+            SEND_LINE, "* OK localhost test server ready"
+        );
+        this.server.add_script_line(RECEIVE_LINE, "a001 TEST");
+        this.server.add_script_line(WAIT_FOR_DISCONNECT, "");
+
+        const int TIMEOUT = 2;
+
+        bool sent = false;
+        bool recv_fail = false;
+        bool timed_out = false;
+
+        var test_article = new ClientConnection(new_endpoint(), TIMEOUT);
+        test_article.sent_command.connect(() => { sent = true; });
+        test_article.receive_failure.connect(() => { recv_fail = true; });
+        test_article.connect_async.begin(null, this.async_complete_full);
+        test_article.connect_async.end(async_result());
+
+        var command = new TestCommand();
+        command.response_timed_out.connect(() => { timed_out = true; });
+
+        test_article.send_command(command);
+
+        GLib.Timer timer = new GLib.Timer();
+        timer.start();
+        while (!timed_out && timer.elapsed() < TIMEOUT * 2) {
+            this.main_loop.iteration(false);
+        }
+
+        test_article.disconnect_async.begin(null, this.async_complete_full);
+        test_article.disconnect_async.end(async_result());
+
+        assert_true(sent, "connection.sent_command");
+        assert_true(recv_fail, "command.receive_failure");
+        assert_true(timed_out, "command.response_timed_out");
+
+        debug("Waiting for server...");
+
+        TestServer.Result result = this.server.wait_for_script(this.main_loop);
+        assert_true(result.succeeded);
+    }
+
+    protected Endpoint new_endpoint() {
+        return new Endpoint(this.server.get_client_address(), NONE, 10);
+    }
+
+}
diff --git a/test/engine/imap/transport/imap-client-session-test.vala 
b/test/engine/imap/transport/imap-client-session-test.vala
new file mode 100644
index 00000000..7ff91357
--- /dev/null
+++ b/test/engine/imap/transport/imap-client-session-test.vala
@@ -0,0 +1,415 @@
+/*
+ * Copyright 2019 Michael Gratton <mike vee net>
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+class Geary.Imap.ClientSessionTest : TestCase {
+
+    private const uint CONNECT_TIMEOUT = 2;
+
+    private TestServer? server = null;
+
+
+    public ClientSessionTest() {
+        base("Geary.Imap.ClientSessionTest");
+        add_test("connect_disconnect", connect_disconnect);
+        add_test("connect_with_capabilities", connect_with_capabilities);
+        if (GLib.Test.slow()) {
+            add_test("connect_timeout", connect_timeout);
+        }
+        add_test("login", login);
+        add_test("login_with_capabilities", login_with_capabilities);
+        add_test("logout", logout);
+        add_test("login_logout", login_logout);
+        add_test("initiate_request_capabilities", initiate_request_capabilities);
+        add_test("initiate_implicit_capabilities", initiate_implicit_capabilities);
+        add_test("initiate_namespace", initiate_namespace);
+    }
+
+    protected override void set_up() throws GLib.Error {
+        this.server = new TestServer();
+    }
+
+    protected override void tear_down() {
+        this.server.stop();
+        this.server = null;
+    }
+
+    public void connect_disconnect() throws GLib.Error {
+        this.server.add_script_line(
+            SEND_LINE, "* OK localhost test server ready"
+        );
+        this.server.add_script_line(WAIT_FOR_DISCONNECT, "");
+
+        var test_article = new ClientSession(new_endpoint());
+        assert_true(test_article.get_protocol_state() == NOT_CONNECTED);
+
+        test_article.connect_async.begin(
+            CONNECT_TIMEOUT, null, this.async_complete_full
+        );
+        test_article.connect_async.end(async_result());
+        assert_true(test_article.get_protocol_state() == UNAUTHORIZED);
+
+        test_article.disconnect_async.begin(null, this.async_complete_full);
+        test_article.disconnect_async.end(async_result());
+        assert_true(test_article.get_protocol_state() == NOT_CONNECTED);
+
+        TestServer.Result result = this.server.wait_for_script(this.main_loop);
+        assert_true(
+            result.succeeded,
+            result.error != null ? result.error.message : "Server result failed"
+        );
+    }
+
+    public void connect_with_capabilities() throws GLib.Error {
+        this.server.add_script_line(
+            SEND_LINE, "* OK [CAPABILITY IMAP4rev1] localhost test server ready"
+        );
+        this.server.add_script_line(WAIT_FOR_DISCONNECT, "");
+
+        var test_article = new ClientSession(new_endpoint());
+        test_article.connect_async.begin(
+            CONNECT_TIMEOUT, null, this.async_complete_full
+        );
+        test_article.connect_async.end(async_result());
+
+        assert_true(test_article.capabilities.supports_imap4rev1());
+
+        test_article.disconnect_async.begin(null, this.async_complete_full);
+        test_article.disconnect_async.end(async_result());
+
+        TestServer.Result result = this.server.wait_for_script(this.main_loop);
+        assert_true(result.succeeded);
+    }
+
+    public void connect_timeout() throws GLib.Error {
+        this.server.add_script_line(WAIT_FOR_DISCONNECT, "");
+
+        var test_article = new ClientSession(new_endpoint());
+
+        GLib.Timer timer = new GLib.Timer();
+        timer.start();
+        test_article.connect_async.begin(
+            CONNECT_TIMEOUT, null, this.async_complete_full
+        );
+        try {
+            test_article.connect_async.end(async_result());
+            assert_not_reached();
+        } catch (GLib.IOError.TIMED_OUT err) {
+            assert_double(timer.elapsed(), CONNECT_TIMEOUT, CONNECT_TIMEOUT * 0.5);
+        }
+
+        TestServer.Result result = this.server.wait_for_script(this.main_loop);
+        assert_true(result.succeeded);
+    }
+
+    public void login_with_capabilities() throws GLib.Error {
+        this.server.add_script_line(
+            SEND_LINE, "* OK localhost test server ready"
+        );
+        this.server.add_script_line(RECEIVE_LINE, "a001 login test password");
+        this.server.add_script_line(
+            SEND_LINE, "a001 OK [CAPABILITY IMAP4rev1] ohhai"
+        );
+        this.server.add_script_line(WAIT_FOR_DISCONNECT, "");
+
+        var test_article = new ClientSession(new_endpoint());
+        test_article.connect_async.begin(
+            CONNECT_TIMEOUT, null, this.async_complete_full
+        );
+        test_article.connect_async.end(async_result());
+        test_article.login_async.begin(
+            new Credentials(PASSWORD, "test", "password"),
+            null,
+            this.async_complete_full
+        );
+        test_article.login_async.end(async_result());
+
+        assert_true(test_article.capabilities.supports_imap4rev1());
+
+        test_article.disconnect_async.begin(null, this.async_complete_full);
+        test_article.disconnect_async.end(async_result());
+
+        TestServer.Result result = this.server.wait_for_script(this.main_loop);
+        assert_true(
+            result.succeeded,
+            result.error != null ? result.error.message : "Server result failed"
+        );
+    }
+
+    public void login() throws GLib.Error {
+        this.server.add_script_line(
+            SEND_LINE, "* OK localhost test server ready"
+        );
+        this.server.add_script_line(RECEIVE_LINE, "a001 login test password");
+        this.server.add_script_line(SEND_LINE, "a001 OK ohhai");
+        this.server.add_script_line(WAIT_FOR_DISCONNECT, "");
+
+        var test_article = new ClientSession(new_endpoint());
+        assert_true(test_article.get_protocol_state() == NOT_CONNECTED);
+
+        test_article.connect_async.begin(
+            CONNECT_TIMEOUT, null, this.async_complete_full
+        );
+        test_article.connect_async.end(async_result());
+        assert_true(test_article.get_protocol_state() == UNAUTHORIZED);
+
+        test_article.login_async.begin(
+            new Credentials(PASSWORD, "test", "password"),
+            null,
+            this.async_complete_full
+        );
+        test_article.login_async.end(async_result());
+        assert_true(test_article.get_protocol_state() == AUTHORIZED);
+
+        test_article.disconnect_async.begin(null, this.async_complete_full);
+        test_article.disconnect_async.end(async_result());
+        assert_true(test_article.get_protocol_state() == NOT_CONNECTED);
+
+        TestServer.Result result = this.server.wait_for_script(this.main_loop);
+        assert_true(
+            result.succeeded,
+            result.error != null ? result.error.message : "Server result failed"
+        );
+    }
+
+    public void logout() throws GLib.Error {
+        this.server.add_script_line(
+            SEND_LINE, "* OK localhost test server ready"
+        );
+        this.server.add_script_line(RECEIVE_LINE, "a001 logout");
+        this.server.add_script_line(SEND_LINE, "* BYE fine");
+        this.server.add_script_line(SEND_LINE, "a001 OK laters");
+        this.server.add_script_line(DISCONNECT, "");
+
+        var test_article = new ClientSession(new_endpoint());
+        assert_true(test_article.get_protocol_state() == NOT_CONNECTED);
+
+        test_article.connect_async.begin(
+            CONNECT_TIMEOUT, null, this.async_complete_full
+        );
+        test_article.connect_async.end(async_result());
+        assert_true(test_article.get_protocol_state() == UNAUTHORIZED);
+
+        test_article.logout_async.begin(null, this.async_complete_full);
+        test_article.logout_async.end(async_result());
+        assert_true(test_article.get_protocol_state() == NOT_CONNECTED);
+
+        TestServer.Result result = this.server.wait_for_script(this.main_loop);
+        assert_true(
+            result.succeeded,
+            result.error != null ? result.error.message : "Server result failed"
+        );
+    }
+
+    public void login_logout() throws GLib.Error {
+        this.server.add_script_line(
+            SEND_LINE, "* OK localhost test server ready"
+        );
+        this.server.add_script_line(RECEIVE_LINE, "a001 login test password");
+        this.server.add_script_line(SEND_LINE, "a001 OK ohhai");
+        this.server.add_script_line(RECEIVE_LINE, "a002 logout");
+        this.server.add_script_line(SEND_LINE, "* BYE fine");
+        this.server.add_script_line(SEND_LINE, "a002 OK laters");
+        this.server.add_script_line(DISCONNECT, "");
+
+        var test_article = new ClientSession(new_endpoint());
+        assert_true(test_article.get_protocol_state() == NOT_CONNECTED);
+
+        test_article.connect_async.begin(
+            CONNECT_TIMEOUT, null, this.async_complete_full
+        );
+        test_article.connect_async.end(async_result());
+        assert_true(test_article.get_protocol_state() == UNAUTHORIZED);
+
+        test_article.login_async.begin(
+            new Credentials(PASSWORD, "test", "password"),
+            null,
+            this.async_complete_full
+        );
+        test_article.login_async.end(async_result());
+        assert_true(test_article.get_protocol_state() == AUTHORIZED);
+
+        test_article.logout_async.begin(null, this.async_complete_full);
+        test_article.logout_async.end(async_result());
+        assert_true(test_article.get_protocol_state() == NOT_CONNECTED);
+
+        TestServer.Result result = this.server.wait_for_script(this.main_loop);
+        assert_true(
+            result.succeeded,
+            result.error != null ? result.error.message : "Server result failed"
+        );
+    }
+
+    public void initiate_request_capabilities() throws GLib.Error {
+        this.server.add_script_line(
+            SEND_LINE, "* OK localhost test server ready"
+        );
+        this.server.add_script_line(RECEIVE_LINE, "a001 capability");
+        this.server.add_script_line(SEND_LINE, "* CAPABILITY IMAP4rev1 LOGIN");
+        this.server.add_script_line(SEND_LINE, "a001 OK enjoy");
+        this.server.add_script_line(RECEIVE_LINE, "a002 login test password");
+        this.server.add_script_line(SEND_LINE, "a002 OK ohhai");
+        this.server.add_script_line(RECEIVE_LINE, "a003 capability");
+        this.server.add_script_line(SEND_LINE, "* CAPABILITY IMAP4rev1");
+        this.server.add_script_line(SEND_LINE, "a003 OK thanks");
+        this.server.add_script_line(RECEIVE_LINE, "a004 LIST \"\" INBOX");
+        this.server.add_script_line(SEND_LINE, "* LIST (\\HasChildren) \".\" Inbox");
+        this.server.add_script_line(SEND_LINE, "a004 OK there");
+        this.server.add_script_line(WAIT_FOR_DISCONNECT, "");
+
+        var test_article = new ClientSession(new_endpoint());
+        assert_true(test_article.get_protocol_state() == NOT_CONNECTED);
+
+        test_article.connect_async.begin(
+            CONNECT_TIMEOUT, null, this.async_complete_full
+        );
+        test_article.connect_async.end(async_result());
+        assert_true(test_article.get_protocol_state() == UNAUTHORIZED);
+
+        test_article.initiate_session_async.begin(
+            new Credentials(PASSWORD, "test", "password"),
+            null,
+            this.async_complete_full
+        );
+        test_article.initiate_session_async.end(async_result());
+
+        assert_true(test_article.capabilities.supports_imap4rev1());
+        assert_false(test_article.capabilities.has_capability("AUTH"));
+        assert_int(2, test_article.capabilities.revision);
+
+        assert_string("Inbox", test_article.inbox.mailbox.name);
+        assert_true(test_article.inbox.mailbox.is_inbox);
+
+        test_article.disconnect_async.begin(null, this.async_complete_full);
+        test_article.disconnect_async.end(async_result());
+
+        TestServer.Result result = this.server.wait_for_script(this.main_loop);
+        assert_true(
+            result.succeeded,
+            result.error != null ? result.error.message : "Server result failed"
+        );
+    }
+
+    public void initiate_implicit_capabilities() throws GLib.Error {
+        this.server.add_script_line(
+            SEND_LINE, "* OK [CAPABILITY IMAP4rev1 LOGIN] localhost test server ready"
+        );
+        this.server.add_script_line(RECEIVE_LINE, "a001 login test password");
+        this.server.add_script_line(SEND_LINE, "a001 OK [CAPABILITY IMAP4rev1] ohhai");
+        this.server.add_script_line(RECEIVE_LINE, "a002 LIST \"\" INBOX");
+        this.server.add_script_line(SEND_LINE, "* LIST (\\HasChildren) \".\" Inbox");
+        this.server.add_script_line(SEND_LINE, "a002 OK there");
+        this.server.add_script_line(WAIT_FOR_DISCONNECT, "");
+
+        var test_article = new ClientSession(new_endpoint());
+        assert_true(test_article.get_protocol_state() == NOT_CONNECTED);
+
+        test_article.connect_async.begin(
+            CONNECT_TIMEOUT, null, this.async_complete_full
+        );
+        test_article.connect_async.end(async_result());
+        assert_true(test_article.get_protocol_state() == UNAUTHORIZED);
+
+        test_article.initiate_session_async.begin(
+            new Credentials(PASSWORD, "test", "password"),
+            null,
+            this.async_complete_full
+        );
+        test_article.initiate_session_async.end(async_result());
+
+        assert_true(test_article.capabilities.supports_imap4rev1());
+        assert_false(test_article.capabilities.has_capability("AUTH"));
+        assert_int(2, test_article.capabilities.revision);
+
+        assert_string("Inbox", test_article.inbox.mailbox.name);
+        assert_true(test_article.inbox.mailbox.is_inbox);
+
+        test_article.disconnect_async.begin(null, this.async_complete_full);
+        test_article.disconnect_async.end(async_result());
+
+        TestServer.Result result = this.server.wait_for_script(this.main_loop);
+        assert_true(
+            result.succeeded,
+            result.error != null ? result.error.message : "Server result failed"
+        );
+    }
+
+    public void initiate_namespace() throws GLib.Error {
+        this.server.add_script_line(
+            SEND_LINE,
+            "* OK [CAPABILITY IMAP4rev1 LOGIN] localhost test server ready"
+        );
+        this.server.add_script_line(
+            RECEIVE_LINE, "a001 login test password"
+        );
+        this.server.add_script_line(
+            SEND_LINE, "a001 OK [CAPABILITY IMAP4rev1 NAMESPACE] ohhai"
+        );
+        this.server.add_script_line(
+            RECEIVE_LINE, "a002 LIST \"\" INBOX"
+        );
+        this.server.add_script_line(
+            SEND_LINE, "* LIST (\\HasChildren) \".\" Inbox"
+        );
+        this.server.add_script_line(
+            SEND_LINE, "a002 OK there"
+        );
+        this.server.add_script_line(
+            RECEIVE_LINE, "a003 NAMESPACE"
+        );
+        this.server.add_script_line(
+            SEND_LINE,
+            """* NAMESPACE (("INBOX." ".")) (("user." ".")) (("shared." "."))"""
+        );
+        this.server.add_script_line(SEND_LINE, "a003 OK there");
+        this.server.add_script_line(WAIT_FOR_DISCONNECT, "");
+
+        var test_article = new ClientSession(new_endpoint());
+        assert_true(test_article.get_protocol_state() == NOT_CONNECTED);
+
+        test_article.connect_async.begin(
+            CONNECT_TIMEOUT, null, this.async_complete_full
+        );
+        test_article.connect_async.end(async_result());
+        assert_true(test_article.get_protocol_state() == UNAUTHORIZED);
+
+        test_article.initiate_session_async.begin(
+            new Credentials(PASSWORD, "test", "password"),
+            null,
+            this.async_complete_full
+        );
+        test_article.initiate_session_async.end(async_result());
+
+        assert_int(1, test_article.get_personal_namespaces().size);
+        assert_string(
+            "INBOX.", test_article.get_personal_namespaces()[0].prefix
+        );
+
+        assert_int(1, test_article.get_shared_namespaces().size);
+        assert_string(
+            "shared.", test_article.get_shared_namespaces()[0].prefix
+        );
+
+        assert_int(1, test_article.get_other_users_namespaces().size);
+        assert_string(
+            "user.", test_article.get_other_users_namespaces()[0].prefix
+        );
+
+        test_article.disconnect_async.begin(null, this.async_complete_full);
+        test_article.disconnect_async.end(async_result());
+
+        TestServer.Result result = this.server.wait_for_script(this.main_loop);
+        assert_true(
+            result.succeeded,
+            result.error != null ? result.error.message : "Server result failed"
+        );
+    }
+
+    protected Endpoint new_endpoint() {
+        return new Endpoint(this.server.get_client_address(), NONE, 10);
+    }
+
+}
diff --git a/test/engine/imap/transport/imap-deserializer-test.vala 
b/test/engine/imap/transport/imap-deserializer-test.vala
index 0960331d..f090057e 100644
--- a/test/engine/imap/transport/imap-deserializer-test.vala
+++ b/test/engine/imap/transport/imap-deserializer-test.vala
@@ -265,7 +265,7 @@ class Geary.Imap.DeserializerTest : TestCase {
         this.stream.add_data(bye.data);
 
         bool eos = false;
-        this.deser.eos.connect(() => { eos = true; });
+        this.deser.end_of_stream.connect(() => { eos = true; });
 
         this.process.begin(Expect.MESSAGE, (obj, ret) => { async_complete(ret); });
         RootParameters? message = this.process.end(async_result());
@@ -283,7 +283,7 @@ class Geary.Imap.DeserializerTest : TestCase {
 
         this.deser.parameters_ready.connect((param) => { message = param; });
         this.deser.bytes_received.connect((count) => { bytes_received += count; });
-        this.deser.eos.connect((param) => { eos = true; });
+        this.deser.end_of_stream.connect((param) => { eos = true; });
         this.deser.deserialize_failure.connect(() => { deserialize_failure = true; });
         this.deser.receive_failure.connect((err) => { receive_failure = true;});
 
diff --git a/test/engine/util-timeout-manager-test.vala b/test/engine/util-timeout-manager-test.vala
index a9a01a64..dcecba8d 100644
--- a/test/engine/util-timeout-manager-test.vala
+++ b/test/engine/util-timeout-manager-test.vala
@@ -87,7 +87,7 @@ class Geary.TimeoutManagerTest : TestCase {
             this.main_loop.iteration(true);
         }
 
-        assert_epsilon(timer.elapsed(), 1.0, SECONDS_EPSILON);
+        assert_double(timer.elapsed(), 1.0, SECONDS_EPSILON);
     }
 
     public void milliseconds() throws Error {
@@ -101,7 +101,7 @@ class Geary.TimeoutManagerTest : TestCase {
             this.main_loop.iteration(true);
         }
 
-        assert_epsilon(timer.elapsed(), 0.1, MILLISECONDS_EPSILON);
+        assert_double(timer.elapsed(), 0.1, MILLISECONDS_EPSILON);
     }
 
     public void repeat_forever() throws Error {
@@ -118,11 +118,7 @@ class Geary.TimeoutManagerTest : TestCase {
         }
         timer.stop();
 
-        assert_epsilon(timer.elapsed(), 2.0, SECONDS_EPSILON * 2);
-    }
-
-    private inline void assert_epsilon(double actual, double expected, double epsilon) {
-        assert(actual + epsilon >= expected && actual - epsilon <= expected);
+        assert_double(timer.elapsed(), 2.0, SECONDS_EPSILON * 2);
     }
 
 }
diff --git a/test/integration/imap/client-session.vala b/test/integration/imap/client-session.vala
index e2d38a63..f6221f63 100644
--- a/test/integration/imap/client-session.vala
+++ b/test/integration/imap/client-session.vala
@@ -33,7 +33,7 @@ class Integration.Imap.ClientSession : TestCase {
     }
 
     public override void tear_down() throws GLib.Error {
-        if (this.session.get_protocol_state(null) != NOT_CONNECTED) {
+        if (this.session.get_protocol_state() != NOT_CONNECTED) {
             this.session.disconnect_async.begin(null, async_complete_full);
             this.session.disconnect_async.end(async_result());
         }
@@ -41,7 +41,7 @@ class Integration.Imap.ClientSession : TestCase {
     }
 
     public void session_connect() throws GLib.Error {
-        this.session.connect_async.begin(null, async_complete_full);
+        this.session.connect_async.begin(2, null, async_complete_full);
         this.session.connect_async.end(async_result());
 
         this.session.disconnect_async.begin(null, async_complete_full);
@@ -98,7 +98,7 @@ class Integration.Imap.ClientSession : TestCase {
     }
 
     private void do_connect() throws GLib.Error {
-        this.session.connect_async.begin(null, async_complete_full);
+        this.session.connect_async.begin(5, null, async_complete_full);
         this.session.connect_async.end(async_result());
     }
 
diff --git a/test/meson.build b/test/meson.build
index 38a3aae2..9cd4717f 100644
--- a/test/meson.build
+++ b/test/meson.build
@@ -3,6 +3,7 @@ subdir('data')
 geary_test_lib_sources = [
   'mock-object.vala',
   'test-case.vala',
+  'test-server.vala',
 ]
 
 geary_test_engine_sources = [
@@ -41,6 +42,8 @@ geary_test_engine_sources = [
   'engine/imap/message/imap-mailbox-specifier-test.vala',
   'engine/imap/parameter/imap-list-parameter-test.vala',
   'engine/imap/response/imap-namespace-response-test.vala',
+  'engine/imap/transport/imap-client-connection-test.vala',
+  'engine/imap/transport/imap-client-session-test.vala',
   'engine/imap/transport/imap-deserializer-test.vala',
   'engine/imap-db/imap-db-account-test.vala',
   'engine/imap-db/imap-db-attachment-test.vala',
diff --git a/test/test-case.vala b/test/test-case.vala
index 7e65603d..6292c498 100644
--- a/test/test-case.vala
+++ b/test/test-case.vala
@@ -96,6 +96,10 @@ public void assert_int64(int64 expected, int64 actual, string? context = null)
     }
 }
 
+public void assert_double(double actual, double expected, double epsilon) {
+    assert(actual + epsilon >= expected && actual - epsilon <= expected);
+}
+
 public void assert_uint(uint expected, uint actual, string? context = null)
     throws GLib.Error {
     if (expected != actual) {
diff --git a/test/test-engine.vala b/test/test-engine.vala
index a8e262fa..a70cb11c 100644
--- a/test/test-engine.vala
+++ b/test/test-engine.vala
@@ -46,20 +46,28 @@ int main(string[] args) {
     engine.add_suite(new Geary.Db.DatabaseTest().get_suite());
     engine.add_suite(new Geary.Db.VersionedDatabaseTest().get_suite());
     engine.add_suite(new Geary.HTML.UtilTest().get_suite());
-    // Other IMAP tests rely on DataFormat working, so test that first
+
+    // Other IMAP tests rely on these working, so test them first
     engine.add_suite(new Geary.Imap.DataFormatTest().get_suite());
+
     engine.add_suite(new Geary.Imap.CreateCommandTest().get_suite());
-    engine.add_suite(new Geary.Imap.DeserializerTest().get_suite());
     engine.add_suite(new Geary.Imap.FetchCommandTest().get_suite());
     engine.add_suite(new Geary.Imap.ListParameterTest().get_suite());
     engine.add_suite(new Geary.Imap.MailboxSpecifierTest().get_suite());
     engine.add_suite(new Geary.Imap.NamespaceResponseTest().get_suite());
+
+    // Depends on IMAP commands working
+    engine.add_suite(new Geary.Imap.DeserializerTest().get_suite());
+    engine.add_suite(new Geary.Imap.ClientConnectionTest().get_suite());
+    engine.add_suite(new Geary.Imap.ClientSessionTest().get_suite());
+
     engine.add_suite(new Geary.ImapDB.AccountTest().get_suite());
     engine.add_suite(new Geary.ImapDB.AttachmentTest().get_suite());
     engine.add_suite(new Geary.ImapDB.AttachmentIoTest().get_suite());
     engine.add_suite(new Geary.ImapDB.DatabaseTest().get_suite());
     engine.add_suite(new Geary.ImapDB.EmailIdentifierTest().get_suite());
     engine.add_suite(new Geary.ImapDB.FolderTest().get_suite());
+
     engine.add_suite(new Geary.ImapEngine.AccountProcessorTest().get_suite());
     engine.add_suite(new Geary.ImapEngine.GenericAccountTest().get_suite());
 
diff --git a/test/test-server.vala b/test/test-server.vala
new file mode 100644
index 00000000..39e99e70
--- /dev/null
+++ b/test/test-server.vala
@@ -0,0 +1,222 @@
+/*
+ * Copyright 2019 Michael Gratton <mike vee net>
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+/**
+ * A simple mock server for testing network connections.
+ *
+ * To use it, unit tests should construct an instance as a fixture in
+ * set up, specify a test script by adding lines and then check the
+ * result, before stopping the server in tear down.
+ */
+public class TestServer : GLib.Object {
+
+
+    /** Possible actions a script may take. */
+    public enum Action {
+        /**
+         * The implicit first action.
+         *
+         * This does not need to be specified as a script action, it
+         * will always be taken when a client connects.
+         */
+        CONNECTED,
+
+        /** Send a line to the client. */
+        SEND_LINE,
+
+        /** Receive a line from the client. */
+        RECEIVE_LINE,
+
+        /** Wait for the client to disconnect. */
+        WAIT_FOR_DISCONNECT,
+
+        /** Disconnect immediately. */
+        DISCONNECT;
+    }
+
+
+    /** A line of the server's script. */
+    public struct Line {
+
+        /** The action to take for this line. */
+        public Action action;
+
+        /**
+         * The value for the action.
+         *
+         * If sending, this string will be sent. If receiving, the
+         * expected line.
+         */
+        public string value;
+
+    }
+
+    /** The result of executing a script line. */
+    public struct Result {
+
+        /** The expected action. */
+        public Line line;
+
+        /** Was the expected action successful. */
+        public bool succeeded;
+
+        /** The actual string sent by a client when not as expected. */
+        public string? actual;
+
+        /** In case of an error being thrown, the error itself. */
+        public GLib.Error? error;
+
+    }
+
+
+    private GLib.DataStreamNewlineType line_ending;
+    private uint16 port;
+    private GLib.ThreadedSocketService service =
+        new GLib.ThreadedSocketService(10);
+    private GLib.Cancellable running = new GLib.Cancellable();
+    private Gee.List<Line?> script = new Gee.ArrayList<Line?>();
+    private GLib.AsyncQueue<Result?> completion_queue =
+        new GLib.AsyncQueue<Result?>();
+
+
+    public TestServer(GLib.DataStreamNewlineType line_ending = CR_LF)
+        throws GLib.Error {
+        this.line_ending = line_ending;
+        this.port = this.service.add_any_inet_port(null);
+        this.service.run.connect((conn) => {
+                handle_connection(conn);
+                return true;
+            });
+        this.service.start();
+    }
+
+    public GLib.SocketConnectable get_client_address() {
+        return new GLib.NetworkAddress("localhost", this.port);
+    }
+
+    public void add_script_line(Action action, string value) {
+        this.script.add({ action, value });
+    }
+
+    public Result wait_for_script(GLib.MainContext loop) {
+        Result? result = null;
+        while (result == null) {
+            loop.iteration(false);
+            result = this.completion_queue.try_pop();
+        }
+        return result;
+    }
+
+    public void stop() {
+        this.service.stop();
+        this.running.cancel();
+    }
+
+    private void handle_connection(GLib.SocketConnection connection) {
+        debug("Connected");
+        var input = new GLib.DataInputStream(
+            connection.input_stream
+        );
+        input.set_newline_type(this.line_ending);
+
+        var output = new GLib.DataOutputStream(
+            connection.output_stream
+        );
+
+        Line connected_line = { CONNECTED, "" };
+        Result result = { connected_line, true, null, null };
+        foreach (var line in this.script) {
+            result.line = line;
+            switch (line.action) {
+            case SEND_LINE:
+                debug("Sending: %s", line.value);
+                try {
+                    output.put_string(line.value);
+                    switch (this.line_ending) {
+                    case CR:
+                        output.put_byte('\r');
+                        break;
+                    case LF:
+                        output.put_byte('\n');
+                        break;
+                    default:
+                        output.put_byte('\r');
+                        output.put_byte('\n');
+                        break;
+                    }
+                } catch (GLib.Error err) {
+                    result.succeeded = false;
+                    result.error = err;
+                }
+                break;
+
+            case RECEIVE_LINE:
+                debug("Waiting for: %s", line.value);
+                try {
+                    size_t len;
+                    string? received = input.read_line(out len, this.running);
+                    if (received == null || received != line.value) {
+                        result.succeeded = false;
+                        result.actual = received;
+                    }
+                } catch (GLib.Error err) {
+                    result.succeeded = false;
+                    result.error = err;
+                }
+                break;
+
+            case WAIT_FOR_DISCONNECT:
+                debug("Waiting for disconnect");
+                var socket = connection.get_socket();
+                try {
+                    uint8 buffer[4096];
+                    while (socket.receive_with_blocking(buffer, true) > 0) { }
+                } catch (GLib.Error err) {
+                    result.succeeded = false;
+                    result.error = err;
+                }
+                break;
+
+            case DISCONNECT:
+                debug("Disconnecting");
+                try {
+                    connection.close(this.running);
+                } catch (GLib.Error err) {
+                    result.succeeded = false;
+                    result.error = err;
+                }
+                break;
+            }
+
+            if (!result.succeeded) {
+                break;
+            }
+        }
+        if (result.succeeded) {
+            debug("Done");
+        } else if (result.error != null) {
+            warning("Error: %s", result.error.message);
+        } else if (result.line.action == RECEIVE_LINE) {
+            warning("Received unexpected line: %s", result.actual ?? "(null)");
+        } else {
+            warning("Failed for unknown reason");
+        }
+
+        if (connection.is_connected()) {
+            try {
+                connection.close(this.running);
+            } catch (GLib.Error err) {
+                warning(
+                    "Error closing test server connection: %s", err.message
+                );
+            }
+        }
+
+        this.completion_queue.push(result);
+    }
+
+}


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]