[geary] Undo Archive/Trash/Move: Bug #721828



commit 354e2edbf8e8fb803feb048d6022073bc22c7d7d
Author: Jim Nelson <jim yorba org>
Date:   Thu Feb 5 17:57:27 2015 -0800

    Undo Archive/Trash/Move: Bug #721828
    
    For now, the undo stack is 1-deep with no redo facility, which mimics
    Gmail's facility.  We can consider later offering a more involved
    undo stack, but would like to try this out for now and work out the
    kinks before becoming more aggressive.

 src/CMakeLists.txt                                 |    8 +-
 src/client/application/geary-application.vala      |   19 ++-
 src/client/application/geary-controller.vala       |  162 ++++++++++++++++--
 src/client/components/main-toolbar.vala            |    6 +
 src/engine/api/geary-abstract-local-folder.vala    |   13 ++-
 src/engine/api/geary-engine.vala                   |   14 +-
 src/engine/api/geary-folder-supports-archive.vala  |    4 +-
 src/engine/api/geary-folder-supports-move.vala     |    4 +-
 src/engine/api/geary-folder.vala                   |    8 +
 src/engine/api/geary-revokable.vala                |  183 ++++++++++++++++++++
 src/engine/app/app-conversation-monitor.vala       |    7 +-
 src/engine/imap-db/imap-db-account.vala            |   47 +++++-
 .../gmail/imap-engine-gmail-folder.vala            |   14 ++-
 .../imap-engine/imap-engine-generic-account.vala   |   40 +++--
 .../imap-engine/imap-engine-minimal-folder.vala    |  111 +++++++++++--
 .../imap-engine/imap-engine-replay-queue.vala      |   21 ++-
 .../imap-engine-revokable-committed-move.vala      |   64 +++++++
 .../imap-engine/imap-engine-revokable-move.vala    |  115 ++++++++++++
 .../imap-engine-send-replay-operation.vala         |    8 +-
 ...ail.vala => imap-engine-move-email-commit.vala} |   75 ++++-----
 .../replay-ops/imap-engine-move-email-prepare.vala |   70 ++++++++
 .../replay-ops/imap-engine-move-email-revoke.vala  |   62 +++++++
 .../replay-ops/imap-engine-user-close.vala         |   45 +++++
 src/engine/imap/api/imap-account.vala              |   10 +-
 .../transport/imap-client-session-manager.vala     |   18 ++-
 25 files changed, 1010 insertions(+), 118 deletions(-)
---
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index bfb58fb..80a715a 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -37,6 +37,7 @@ engine/api/geary-logging.vala
 engine/api/geary-named-flag.vala
 engine/api/geary-named-flags.vala
 engine/api/geary-progress-monitor.vala
+engine/api/geary-revokable.vala
 engine/api/geary-search-folder.vala
 engine/api/geary-search-query.vala
 engine/api/geary-service.vala
@@ -191,6 +192,8 @@ engine/imap-engine/imap-engine-generic-folder.vala
 engine/imap-engine/imap-engine-minimal-folder.vala
 engine/imap-engine/imap-engine-replay-operation.vala
 engine/imap-engine/imap-engine-replay-queue.vala
+engine/imap-engine/imap-engine-revokable-move.vala
+engine/imap-engine/imap-engine-revokable-committed-move.vala
 engine/imap-engine/imap-engine-send-replay-operation.vala
 engine/imap-engine/gmail/imap-engine-gmail-account.vala
 engine/imap-engine/gmail/imap-engine-gmail-all-mail-folder.vala
@@ -211,12 +214,15 @@ engine/imap-engine/replay-ops/imap-engine-fetch-email.vala
 engine/imap-engine/replay-ops/imap-engine-list-email-by-id.vala
 engine/imap-engine/replay-ops/imap-engine-list-email-by-sparse-id.vala
 engine/imap-engine/replay-ops/imap-engine-mark-email.vala
-engine/imap-engine/replay-ops/imap-engine-move-email.vala
+engine/imap-engine/replay-ops/imap-engine-move-email-commit.vala
+engine/imap-engine/replay-ops/imap-engine-move-email-prepare.vala
+engine/imap-engine/replay-ops/imap-engine-move-email-revoke.vala
 engine/imap-engine/replay-ops/imap-engine-remove-email.vala
 engine/imap-engine/replay-ops/imap-engine-replay-append.vala
 engine/imap-engine/replay-ops/imap-engine-replay-disconnect.vala
 engine/imap-engine/replay-ops/imap-engine-replay-removal.vala
 engine/imap-engine/replay-ops/imap-engine-server-search-email.vala
+engine/imap-engine/replay-ops/imap-engine-user-close.vala
 engine/imap-engine/yahoo/imap-engine-yahoo-account.vala
 engine/imap-engine/yahoo/imap-engine-yahoo-folder.vala
 
diff --git a/src/client/application/geary-application.vala b/src/client/application/geary-application.vala
index f533254..5000ae6 100644
--- a/src/client/application/geary-application.vala
+++ b/src/client/application/geary-application.vala
@@ -59,7 +59,6 @@ public class GearyApplication : Gtk.Application {
      * an exit, a callback should return true.
      */
     public virtual signal bool exiting(bool panicked) {
-        controller.close();
         Date.terminate();
         
         return true;
@@ -89,9 +88,9 @@ public class GearyApplication : Gtk.Application {
     
     private string bin;
     private File exec_dir;
-    
     private bool exiting_fired = false;
     private int exitcode = 0;
+    private bool is_destroyed = false;
     
     public GearyApplication() {
         Object(application_id: APP_ID);
@@ -199,6 +198,17 @@ public class GearyApplication : Gtk.Application {
         release();
     }
     
+    private async void destroy_async() {
+        // see create_async() for reasoning hold/release is used
+        hold();
+        
+        yield controller.close_async();
+        
+        release();
+        
+        is_destroyed = true;
+    }
+    
     public bool compose(string mailto) {
         if (controller == null)
             return false;
@@ -327,6 +337,11 @@ public class GearyApplication : Gtk.Application {
             return;
         }
         
+        // Give asynchronous destroy_async() a chance to complete
+        destroy_async.begin();
+        while (!is_destroyed || Gtk.events_pending())
+            Gtk.main_iteration();
+        
         if (Gtk.main_level() > 0)
             Gtk.main_quit();
         else
diff --git a/src/client/application/geary-controller.vala b/src/client/application/geary-controller.vala
index 7a055f4..a8d1fb7 100644
--- a/src/client/application/geary-controller.vala
+++ b/src/client/application/geary-controller.vala
@@ -33,6 +33,7 @@ public class GearyController : Geary.BaseObject {
     public const string ACTION_EMPTY_MENU = "GearyEmptyMenu";
     public const string ACTION_EMPTY_SPAM = "GearyEmptySpam";
     public const string ACTION_EMPTY_TRASH = "GearyEmptyTrash";
+    public const string ACTION_UNDO = "GearyUndo";
     public const string ACTION_FIND_IN_CONVERSATION = "GearyFindInConversation";
     public const string ACTION_FIND_NEXT_IN_CONVERSATION = "GearyFindNextInConversation";
     public const string ACTION_FIND_PREVIOUS_IN_CONVERSATION = "GearyFindPreviousInConversation";
@@ -125,6 +126,7 @@ public class GearyController : Geary.BaseObject {
     private Gee.List<string> pending_mailtos = new Gee.ArrayList<string>();
     private Geary.Nonblocking.Mutex untrusted_host_prompt_mutex = new Geary.Nonblocking.Mutex();
     private Gee.HashSet<Geary.Endpoint> validating_endpoints = new Gee.HashSet<Geary.Endpoint>();
+    private Geary.Revokable? revokable = null;
     
     // List of windows we're waiting to close before Geary closes.
     private Gee.List<ComposerWidget> waiting_to_close = new Gee.ArrayList<ComposerWidget>();
@@ -238,6 +240,9 @@ public class GearyController : Geary.BaseObject {
         // instantiate here to ensure that Config is initialized and ready
         autostart_manager = new AutostartManager();
         
+        // initialize revokable
+        save_revokable(null, null);
+        
         // Start Geary.
         try {
             yield Geary.Engine.instance.open_async(GearyApplication.instance.get_user_data_directory(), 
@@ -251,13 +256,59 @@ public class GearyController : Geary.BaseObject {
     }
     
     /**
-     * Stops the controller and shuts down Geary.
+     * At the moment, this is non-reversible, i.e. once closed a GearyController cannot be
+     * re-opened.
      */
-    public void close() {
+    public async void close_async() {
+        // hide window while shutting down, as this can take a few seconds under certain conditions
+        main_window.hide();
+        
+        // drop the Revokable, which will commit it if necessary
+        save_revokable(null, null);
+        
+        // close the ConversationMonitor
+        try {
+            if (current_conversations != null) {
+                yield current_conversations.stop_monitoring_async(null);
+                
+                // If not an Inbox, wait for it to close so all pending operations are flushed
+                if (!inboxes.values.contains(current_conversations.folder))
+                    yield current_conversations.folder.wait_for_close_async(null);
+            }
+        } catch (Error err) {
+            message("Error closing conversation at shutdown: %s", err.message);
+        } finally {
+            current_conversations = null;
+        }
+        
+        // close all Inboxes
+        foreach (Geary.Folder inbox in inboxes.values) {
+            try {
+                // close and wait for all pending operations to be flushed
+                yield inbox.close_async(null);
+                yield inbox.wait_for_close_async(null);
+            } catch (Error err) {
+                message("Error closing Inbox %s at shutdown: %s", inbox.to_string(), err.message);
+            }
+        }
+        
+        // close all Accounts
+        foreach (Geary.Account account in email_stores.keys) {
+            try {
+                yield account.close_async(null);
+            } catch (Error err) {
+                message("Error closing account %s at shutdown: %s", account.to_string(), err.message);
+            }
+        }
+        
         main_window.destroy();
-        main_window = null;
-        current_account = null;
-        account_selected(null);
+        
+        // Turn off the lights and lock the door behind you
+        try {
+            yield Geary.Engine.instance.close_async(null);
+        } catch (Error err) {
+            message("Error closing Geary Engine instance: %s", err.message);
+        }
     }
     
     private void add_accelerator(string accelerator, string action) {
@@ -404,6 +455,9 @@ public class GearyController : Geary.BaseObject {
         empty_trash.label = _("Empty _Trash…");
         entries += empty_trash;
         
+        Gtk.ActionEntry undo = { ACTION_UNDO, "edit-undo-symbolic", null, "<Ctrl>Z", null, on_revoke };
+        entries += undo;
+        
         Gtk.ActionEntry zoom_in = { ACTION_ZOOM_IN, null, null, "<Ctrl>equal",
             null, on_zoom_in };
         entries += zoom_in;
@@ -1273,6 +1327,9 @@ public class GearyController : Geary.BaseObject {
         Cancellable? conversation_cancellable = (current_is_inbox ?
             inbox_cancellables.get(folder.account) : cancellable_folder);
         
+        // clear Revokable, as Undo is only available while a folder is selected
+        save_revokable(null, null);
+        
         // stop monitoring for conversations and close the folder
         if (current_conversations != null) {
             yield current_conversations.stop_monitoring_async(null);
@@ -1863,10 +1920,19 @@ public class GearyController : Geary.BaseObject {
             return;
         
         Geary.FolderSupport.Move? supports_move = current_folder as Geary.FolderSupport.Move;
-        if (supports_move == null)
-            return;
-        
-        supports_move.move_email_async.begin(ids, destination.path, cancellable_folder);
+        if (supports_move != null)
+            move_conversation_async.begin(supports_move, ids, destination.path, cancellable_folder);
+    }
+    
+    private async void move_conversation_async(Geary.FolderSupport.Move source_folder,
+        Gee.List<Geary.EmailIdentifier> ids, Geary.FolderPath destination, Cancellable? cancellable) {
+        try {
+            save_revokable(yield source_folder.move_email_async(ids, destination, cancellable),
+                _("Undo move (Ctrl+Z)"));
+        } catch (Error err) {
+            debug("%s: Unable to move %d emails: %s", source_folder.to_string(), ids.size,
+                err.message);
+        }
     }
     
     private void on_open_attachment(Geary.Attachment attachment) {
@@ -2404,10 +2470,13 @@ public class GearyController : Geary.BaseObject {
             debug("Archiving selected messages");
             
             Geary.FolderSupport.Archive? supports_archive = current_folder as Geary.FolderSupport.Archive;
-            if (supports_archive == null)
+            if (supports_archive == null) {
                 debug("Folder %s doesn't support archive", current_folder.to_string());
-            else
-                yield supports_archive.archive_email_async(ids, cancellable);
+            } else {
+                save_revokable(yield supports_archive.archive_email_async(ids, cancellable),
+                    _("Undo archive (Ctrl+Z)"));
+            }
+            
             return;
         }
         
@@ -2419,7 +2488,9 @@ public class GearyController : Geary.BaseObject {
                     Geary.SpecialFolderType.TRASH, cancellable)).path;
                 Geary.FolderSupport.Move? supports_move = current_folder as Geary.FolderSupport.Move;
                 if (supports_move != null) {
-                    yield supports_move.move_email_async(ids, trash_path, cancellable);
+                    save_revokable(yield supports_move.move_email_async(ids, trash_path, cancellable),
+                        _("Undo trash (Ctrl+Z)"));
+                    
                     return;
                 }
             }
@@ -2450,6 +2521,71 @@ public class GearyController : Geary.BaseObject {
         }
     }
     
+    private void save_revokable(Geary.Revokable? new_revokable, string? description) {
+        // disconnect old revokable & blindly commit it
+        if (revokable != null) {
+            revokable.notify[Geary.Revokable.PROP_VALID].disconnect(on_revokable_valid_changed);
+            revokable.notify[Geary.Revokable.PROP_IN_PROCESS].disconnect(update_revokable_action);
+            revokable.committed.disconnect(on_revokable_committed);
+            
+            revokable.commit_async.begin();
+        }
+        
+        // store new revokable
+        revokable = new_revokable;
+        
+        // connect to new revokable
+        if (revokable != null) {
+            revokable.notify[Geary.Revokable.PROP_VALID].connect(on_revokable_valid_changed);
+            revokable.notify[Geary.Revokable.PROP_IN_PROCESS].connect(update_revokable_action);
+            revokable.committed.connect(on_revokable_committed);
+        }
+        
+        Gtk.Action undo_action = GearyApplication.instance.get_action(ACTION_UNDO);
+        undo_action.tooltip = (revokable != null && description != null) ? description : _("Undo (Ctrl+Z)");
+        
+        update_revokable_action();
+    }
+    
+    private void update_revokable_action() {
+        Gtk.Action undo_action = GearyApplication.instance.get_action(ACTION_UNDO);
+        undo_action.sensitive = revokable != null && revokable.valid && !revokable.in_process;
+    }
+    
+    private void on_revokable_valid_changed() {
+        // remove revokable if it goes invalid
+        if (revokable != null && !revokable.valid)
+            save_revokable(null, null);
+    }
+    
+    private void on_revokable_committed(Geary.Revokable? committed_revokable) {
+        if (committed_revokable == null)
+            return;
+        
+        // use existing description
+        Gtk.Action undo_action = GearyApplication.instance.get_action(ACTION_UNDO);
+        save_revokable(committed_revokable, undo_action.tooltip);
+    }
+    
+    private void on_revoke() {
+        if (revokable != null && revokable.valid)
+            revokable.revoke_async.begin(null, on_revoke_completed);
+    }
+    
+    private void on_revoke_completed(Object? object, AsyncResult result) {
+        // Don't use the "revokable" instance because it might have gone null before this callback
+        // was reached
+        Geary.Revokable? origin = object as Geary.Revokable;
+        if (origin == null)
+            return;
+        
+        try {
+            origin.revoke_async.end(result);
+        } catch (Error err) {
+            debug("Unable to revoke operation: %s", err.message);
+        }
+    }
+    
     private void on_zoom_in() {
         main_window.conversation_viewer.web_view.zoom_in();
     }
diff --git a/src/client/components/main-toolbar.vala b/src/client/components/main-toolbar.vala
index 79b3555..2250d3b 100644
--- a/src/client/components/main-toolbar.vala
+++ b/src/client/components/main-toolbar.vala
@@ -72,6 +72,10 @@ public class MainToolbar : PillHeaderbar {
         insert.add(trash_delete_button = create_toolbar_button(null, GearyController.ACTION_TRASH_MESSAGE, 
false));
         Gtk.Box archive_trash_delete = create_pill_buttons(insert);
         
+        insert.clear();
+        insert.add(create_toolbar_button(null, GearyController.ACTION_UNDO, false));
+        Gtk.Box undo = create_pill_buttons(insert);
+        
         // Search bar.
         search_entry.width_chars = 28;
         search_entry.tooltip_text = _("Search all mail in account for keywords (Ctrl+S)");
@@ -88,6 +92,7 @@ public class MainToolbar : PillHeaderbar {
         // pack_end() ordering is reversed in GtkHeaderBar in 3.12 and above
 #if !GTK_3_12
         add_end(archive_trash_delete);
+        add_end(undo);
         add_end(search_upgrade_progress_bar);
         add_end(search_entry);
 #endif
@@ -103,6 +108,7 @@ public class MainToolbar : PillHeaderbar {
 #if GTK_3_12
         add_end(search_entry);
         add_end(search_upgrade_progress_bar);
+        add_end(undo);
         add_end(archive_trash_delete);
 #endif
         
diff --git a/src/engine/api/geary-abstract-local-folder.vala b/src/engine/api/geary-abstract-local-folder.vala
index f78dcab..3b32a92 100644
--- a/src/engine/api/geary-abstract-local-folder.vala
+++ b/src/engine/api/geary-abstract-local-folder.vala
@@ -8,11 +8,12 @@
  * Handles open/close for local folders.
  */
 public abstract class Geary.AbstractLocalFolder : Geary.Folder {
-    private int open_count = 0;
-    
     private ProgressMonitor _opening_monitor = new 
Geary.ReentrantProgressMonitor(Geary.ProgressType.ACTIVITY);
     public override Geary.ProgressMonitor opening_monitor { get { return _opening_monitor; } }
     
+    private int open_count = 0;
+    private Nonblocking.Semaphore closed_semaphore = new Nonblocking.Semaphore();
+    
     protected AbstractLocalFolder() {
     }
     
@@ -39,6 +40,8 @@ public abstract class Geary.AbstractLocalFolder : Geary.Folder {
         if (open_count++ > 0)
             return false;
         
+        closed_semaphore.reset();
+        
         notify_opened(Geary.Folder.OpenState.LOCAL, properties.email_total);
         
         return true;
@@ -48,8 +51,14 @@ public abstract class Geary.AbstractLocalFolder : Geary.Folder {
         if (open_count == 0 || --open_count > 0)
             return;
         
+        closed_semaphore.blind_notify();
+        
         notify_closed(Geary.Folder.CloseReason.LOCAL_CLOSE);
         notify_closed(Geary.Folder.CloseReason.FOLDER_CLOSED);
     }
+    
+    public override async void wait_for_close_async(Cancellable? cancellable = null) throws Error {
+        yield closed_semaphore.wait_async(cancellable);
+    }
 }
 
diff --git a/src/engine/api/geary-engine.vala b/src/engine/api/geary-engine.vala
index ae807e9..3d2c8b8 100644
--- a/src/engine/api/geary-engine.vala
+++ b/src/engine/api/geary-engine.vala
@@ -136,8 +136,7 @@ public class Geary.Engine : BaseObject {
      * when necessary.
      */
     public async void open_async(File user_data_dir, File resource_dir,
-                                 Geary.CredentialsMediator? authentication_mediator,
-                                 Cancellable? cancellable = null) throws Error {
+        Geary.CredentialsMediator? authentication_mediator, Cancellable? cancellable = null) throws Error {
         // initialize *before* opening the Engine ... all initialize code should assume the Engine
         // is closed
         initialize_library();
@@ -202,16 +201,19 @@ public class Geary.Engine : BaseObject {
     public async void close_async(Cancellable? cancellable = null) throws Error {
         if (!is_open)
             return;
-
-        foreach(AccountInformation account in accounts.values)
+        
+        Gee.Collection<AccountInformation> unavailable_accounts = accounts.values;
+        accounts.clear();
+        
+        foreach(AccountInformation account in unavailable_accounts)
             account_unavailable(account);
-
+        
         user_data_dir = null;
         resource_dir = null;
         authentication_mediator = null;
         accounts = null;
         account_instances = null;
-
+        
         is_open = false;
         closed();
     }
diff --git a/src/engine/api/geary-folder-supports-archive.vala 
b/src/engine/api/geary-folder-supports-archive.vala
index 02bcde8..a57d8d1 100644
--- a/src/engine/api/geary-folder-supports-archive.vala
+++ b/src/engine/api/geary-folder-supports-archive.vala
@@ -19,8 +19,10 @@ public interface Geary.FolderSupport.Archive : Geary.Folder {
      * Archives the specified emails from the folder.
      *
      * The { link Geary.Folder} must be opened prior to attempting this operation.
+     *
+     * @returns A { link Geary.Revokable} that may be used to revoke (undo) this operation later.
      */
-    public abstract async void archive_email_async(Gee.List<Geary.EmailIdentifier> email_ids,
+    public abstract async Geary.Revokable? archive_email_async(Gee.List<Geary.EmailIdentifier> email_ids,
         Cancellable? cancellable = null) throws Error;
 }
 
diff --git a/src/engine/api/geary-folder-supports-move.vala b/src/engine/api/geary-folder-supports-move.vala
index e4f45c3..2655c7d 100644
--- a/src/engine/api/geary-folder-supports-move.vala
+++ b/src/engine/api/geary-folder-supports-move.vala
@@ -20,8 +20,10 @@ public interface Geary.FolderSupport.Move : Geary.Folder {
      * way but will return success.
      *
      * The { link Geary.Folder} must be opened prior to attempting this operation.
+     *
+     * @returns A { link Geary.Revokable} that may be used to revoke (undo) this operation later.
      */
-    public abstract async void move_email_async(Gee.List<Geary.EmailIdentifier> to_move,
+    public abstract async Geary.Revokable? move_email_async(Gee.List<Geary.EmailIdentifier> to_move,
         Geary.FolderPath destination, Cancellable? cancellable = null) throws Error;
 }
 
diff --git a/src/engine/api/geary-folder.vala b/src/engine/api/geary-folder.vala
index 8f76fb3..cba29de 100644
--- a/src/engine/api/geary-folder.vala
+++ b/src/engine/api/geary-folder.vala
@@ -457,6 +457,14 @@ public abstract class Geary.Folder : BaseObject {
     public abstract async void close_async(Cancellable? cancellable = null) throws Error;
     
     /**
+     * Wait for the Folder to fully close.
+     *
+     * Unlike { link wait_for_open_async}, this will ''always'' block until a { link Folder} is
+     * closed, even if it's not open.
+     */
+    public abstract async void wait_for_close_async(Cancellable? cancellable = null) throws Error;
+    
+    /**
      * Find the lowest- and highest-ordered { link EmailIdentifier}s in the
      * folder, among the given set of EmailIdentifiers that may or may not be
      * in the folder.  If none of the given set are in the folder, return null.
diff --git a/src/engine/api/geary-revokable.vala b/src/engine/api/geary-revokable.vala
new file mode 100644
index 0000000..ccee2db
--- /dev/null
+++ b/src/engine/api/geary-revokable.vala
@@ -0,0 +1,183 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+/**
+ * A representation of an operation with the Geary Engine that may be revoked (undone) at a later
+ * time.
+ *
+ * The Revokable will do everything it can to commit the operation (if necessary) when its final
+ * ref is dropped.  However, since the final ref can be dropped at an indeterminate time, it's
+ * advised that callers force the matter by scheduling it with { link commit_async}.
+ */
+
+public abstract class Geary.Revokable : BaseObject {
+    public const string PROP_VALID = "valid";
+    public const string PROP_IN_PROCESS = "in-process";
+    
+    /**
+     * Indicates if { link revoke_async} or { link commit_async} are valid operations for this
+     * { link Revokable}.
+     *
+     * Due to later operations or notifications, it's possible for the Revokable to go invalid
+     * after being issued to the caller.
+     */
+    public bool valid { get; protected set; default = true; }
+    
+    /**
+     * Indicates a { link revoke_async} or { link commit_async} operation is underway.
+     *
+     * Only one operation can occur at a time, and when complete the { link Revokable} will be
+     * invalid.
+     *
+     * @see valid
+     */
+    public bool in_process { get; protected set; default = false; }
+    
+    private uint commit_timeout_id = 0;
+    
+    /**
+     * Fired when the { link Revokable} has been revoked.
+     *
+     * { link valid} will stil be true when this is fired.
+     */
+    public signal void revoked();
+    
+    /**
+     * Fired when the { link Revokable} has been committed.
+     *
+     * Some Revokables will offer a new Revokable to allow revoking the committed state.
+     *
+     * { link valid} will stil be true when this is fired.
+     */
+    public signal void committed(Geary.Revokable? commit_revokable);
+    
+    /**
+     * Create a { link Revokable} with optional parameters.
+     *
+     * If commit_timeout_sec is nonzero, Revokable will automatically call { link commit_async}
+     * after the timeout expires if it is still { link valid}.
+     */
+    protected Revokable(int commit_timeout_sec = 0) {
+        if (commit_timeout_sec == 0)
+            return;
+        
+        // This holds a reference to the Revokable, meaning cancelling the timeout in the dtor is
+        // largely symbolic, but so be it
+        commit_timeout_id = Timeout.add_seconds(commit_timeout_sec, on_timed_commit);
+        
+        // various events that cancel the need for a timed commit; this is important to drop the
+        // ref to this object within the event loop
+        revoked.connect(cancel_timed_commit);
+        committed.connect(cancel_timed_commit);
+        notify[PROP_VALID].connect(cancel_timed_commit);
+    }
+    
+    ~Revokable() {
+        cancel_timed_commit();
+    }
+    
+    protected virtual void notify_revoked() {
+        revoked();
+    }
+    
+    protected virtual void notify_committed(Geary.Revokable? commit_revokable) {
+        committed(commit_revokable);
+    }
+    
+    /**
+     * Revoke (undo) the operation.
+     *
+     * If the call throws an Error that does not necessarily mean the { link Revokable} is
+     * invalid.  Check { link valid}.
+     *
+     * @throws EngineError.ALREADY_OPEN if { link in_process} is true.  EngineError.ALREADY_CLOSED
+     * if { link valid} is false.
+     */
+    public virtual async void revoke_async(Cancellable? cancellable = null) throws Error {
+        if (in_process)
+            throw new EngineError.ALREADY_OPEN("Already revoking or committing operation");
+        
+        if (!valid)
+            throw new EngineError.ALREADY_CLOSED("Revokable not valid");
+        
+        in_process = true;
+        try {
+            yield internal_revoke_async(cancellable);
+        } finally {
+            in_process = false;
+        }
+    }
+    
+    /**
+     * The child class's implementation of { link revoke_async}.
+     *
+     * The default implementation of { link revoke_async} deals with state issues
+     * ({ link in_process}, throwing the appropriate Error, etc.)  Child classes can override this
+     * method and only worry about the revoke operation itself.
+     *
+     * This call *must* set { link valid} before exiting.  It must also call { link notify_revoked}
+     * if successful.
+     */
+    protected abstract async void internal_revoke_async(Cancellable? cancellable) throws Error;
+    
+    /**
+     * Commits (completes) the operation immediately.
+     *
+     * Some { link Revokable} operations work by delaying the operation until time has passed or
+     * some situation occurs which requires the operation to complete.  This call forces the
+     * operation to complete immediately rather than delay it for later.
+     *
+     * Even if the operation "actually" commits and is not delayed, calling commit_async() will
+     * make this Revokable invalid.
+     *
+     * @throws EngineError.ALREADY_OPEN if { link in_process} is true.  EngineError.ALREADY_CLOSED
+     * if { link valid} is false.
+     */
+    public virtual async void commit_async(Cancellable? cancellable = null) throws Error {
+        if (in_process)
+            throw new EngineError.ALREADY_OPEN("Already revoking or committing operation");
+        
+        if (!valid)
+            throw new EngineError.ALREADY_CLOSED("Revokable not valid");
+        
+        in_process = true;
+        try {
+            yield internal_commit_async(cancellable);
+        } finally {
+            in_process = false;
+        }
+    }
+    
+    /**
+     * The child class's implementation of { link commit_async}.
+     *
+     * The default implementation of { link commit_async} deals with state issues
+     * ({ link in_process}, throwing the appropriate Error, etc.)  Child classes can override this
+     * method and only worry about the revoke operation itself.
+     *
+     * This call *must* set { link valid} before exiting.  It must also call { link notify_committed}
+     * if successful.
+     */
+    protected abstract async void internal_commit_async(Cancellable? cancellable) throws Error;
+    
+    private bool on_timed_commit() {
+        commit_timeout_id = 0;
+        
+        if (valid && !in_process)
+            commit_async.begin();
+        
+        return false;
+    }
+    
+    private void cancel_timed_commit() {
+        if (commit_timeout_id == 0)
+            return;
+        
+        Source.remove(commit_timeout_id);
+        commit_timeout_id = 0;
+    }
+}
+
diff --git a/src/engine/app/app-conversation-monitor.vala b/src/engine/app/app-conversation-monitor.vala
index db4373a..48943c1 100644
--- a/src/engine/app/app-conversation-monitor.vala
+++ b/src/engine/app/app-conversation-monitor.vala
@@ -631,7 +631,7 @@ public class Geary.App.ConversationMonitor : BaseObject {
     }
     
     internal async void remove_emails_async(Gee.Collection<Geary.EmailIdentifier> removed_ids) {
-        debug("%d messages(s) removed to %s, trimming/removing conversations...", removed_ids.size,
+        debug("%d messages(s) removed from %s, trimming/removing conversations...", removed_ids.size,
             folder.to_string());
         
         Gee.Collection<Geary.App.Conversation> removed;
@@ -732,7 +732,10 @@ public class Geary.App.ConversationMonitor : BaseObject {
      * Attempts to load enough conversations to fill min_window_count.
      */
     internal async void fill_window_async(bool is_insert) {
-        if (!is_monitoring || min_window_count <= conversations.size)
+        if (!is_monitoring)
+            return;
+        
+        if (!is_insert && min_window_count <= conversations.size)
             return;
         
         int initial_message_count = conversations.get_email_count();
diff --git a/src/engine/imap-db/imap-db-account.vala b/src/engine/imap-db/imap-db-account.vala
index b09685e..06cec3a 100644
--- a/src/engine/imap-db/imap-db-account.vala
+++ b/src/engine/imap-db/imap-db-account.vala
@@ -250,12 +250,17 @@ private class Geary.ImapDB.Account : BaseObject {
      * update_uid_info is true.
      */
     public async void update_folder_status_async(Geary.Imap.Folder imap_folder, bool update_uid_info,
-        Cancellable? cancellable) throws Error {
+        bool respect_marked_for_remove, Cancellable? cancellable) throws Error {
         check_open();
         
         Geary.Imap.FolderProperties properties = imap_folder.properties;
         Geary.FolderPath path = imap_folder.path;
         
+        // adjust for marked remove, but don't write these adjustments to the database -- they're
+        // only reflected in memory via the properties
+        int adjust_unseen = 0;
+        int adjust_total = 0;
+        
         yield db.exec_transaction_async(Db.TransactionType.RW, (cx) => {
             int64 parent_id;
             if (!do_fetch_parent_id(cx, path, true, out parent_id, cancellable)) {
@@ -264,6 +269,36 @@ private class Geary.ImapDB.Account : BaseObject {
                 return Db.TransactionOutcome.ROLLBACK;
             }
             
+            int64 folder_id;
+            if (!do_fetch_folder_id(cx, path, false, out folder_id, cancellable))
+                folder_id = Db.INVALID_ROWID;
+            
+            if (respect_marked_for_remove && folder_id != Db.INVALID_ROWID) {
+                Db.Statement stmt = cx.prepare("""
+                    SELECT flags
+                    FROM MessageTable
+                    WHERE id IN (
+                        SELECT message_id
+                        FROM MessageLocationTable
+                        WHERE folder_id = ? AND remove_marker = ?
+                    )
+                """);
+                stmt.bind_rowid(0, folder_id);
+                stmt.bind_bool(1, true);
+                
+                Db.Result results = stmt.exec(cancellable);
+                while (!results.finished) {
+                    adjust_total++;
+                    
+                    Imap.EmailFlags flags = new Imap.EmailFlags(Imap.MessageFlags.deserialize(
+                        results.string_at(0)));
+                    if (flags.contains(EmailFlags.UNREAD))
+                        adjust_unseen++;
+                    
+                    results.next(cancellable);
+                }
+            }
+            
             Db.Statement stmt;
             if (parent_id != Db.INVALID_ROWID) {
                 stmt = cx.prepare(
@@ -298,7 +333,7 @@ private class Geary.ImapDB.Account : BaseObject {
         if (db_folder != null) {
             Imap.FolderProperties local_properties = db_folder.get_properties();
             
-            local_properties.set_status_unseen(properties.unseen);
+            local_properties.set_status_unseen(Numeric.int_floor(properties.unseen - adjust_unseen, 0));
             local_properties.recent = properties.recent;
             local_properties.attrs = properties.attrs;
             
@@ -307,8 +342,12 @@ private class Geary.ImapDB.Account : BaseObject {
                 local_properties.uid_next = properties.uid_next;
             }
             
-            if (properties.status_messages >= 0)
-                local_properties.set_status_message_count(properties.status_messages, false);
+            // only update STATUS MESSAGES count if previously set, but use this count as the
+            // "authoritative" value until another SELECT/EXAMINE or MESSAGES response
+            if (properties.status_messages >= 0) {
+                local_properties.set_status_message_count(
+                    Numeric.int_floor(properties.status_messages - adjust_total, 0), true);
+            }
         }
     }
     
diff --git a/src/engine/imap-engine/gmail/imap-engine-gmail-folder.vala 
b/src/engine/imap-engine/gmail/imap-engine-gmail-folder.vala
index 16e5529..567c9a0 100644
--- a/src/engine/imap-engine/gmail/imap-engine-gmail-folder.vala
+++ b/src/engine/imap-engine/gmail/imap-engine-gmail-folder.vala
@@ -17,9 +17,21 @@ private class Geary.ImapEngine.GmailFolder : MinimalFolder, FolderSupport.Archiv
         return yield base.create_email_async(rfc822, flags, date_received, id, cancellable);
     }
     
-    public async void archive_email_async(Gee.List<Geary.EmailIdentifier> email_ids,
+    public async Geary.Revokable? archive_email_async(Gee.List<Geary.EmailIdentifier> email_ids,
         Cancellable? cancellable = null) throws Error {
+        // Use move_email_async("All Mail") here; Gmail will do the right thing and report
+        // it was copied with the pre-existing All Mail UID (in other words, no actual copy is
+        // performed).  This allows for undoing an archive with the same code path as a move.
+        Geary.Folder? all_mail = account.get_special_folder(Geary.SpecialFolderType.ALL_MAIL);
+        if (all_mail != null)
+            return yield move_email_async(email_ids, all_mail.path, cancellable);
+        
+        // although this shouldn't happen, fall back on our traditional archive, which is simply
+        // to remove the message from this label
+        message("%s: Unable to perform revokable archive: All Mail not found", to_string());
         yield expunge_email_async(email_ids, cancellable);
+        
+        return null;
     }
     
     public async void remove_email_async(Gee.List<Geary.EmailIdentifier> email_ids,
diff --git a/src/engine/imap-engine/imap-engine-generic-account.vala 
b/src/engine/imap-engine/imap-engine-generic-account.vala
index 9c4361c..8d04ccf 100644
--- a/src/engine/imap-engine/imap-engine-generic-account.vala
+++ b/src/engine/imap-engine/imap-engine-generic-account.vala
@@ -319,6 +319,9 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.Account {
         if (in_refresh_unseen.contains(folder))
             return true;
         
+        // add here, remove in completed callback
+        in_refresh_unseen.add(folder);
+        
         refresh_unseen_async.begin(folder, null, on_refresh_unseen_completed);
         
         refresh_unseen_timeout_ids.unset(folder.path);
@@ -334,21 +337,29 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.Account {
     }
     
     private async void refresh_unseen_async(Geary.Folder folder, Cancellable? cancellable) throws Error {
-        in_refresh_unseen.add(folder);
-        
         debug("Refreshing unseen counts for %s", folder.to_string());
         
-        bool folder_created;
-        Imap.Folder remote_folder = yield remote.fetch_folder_async(folder.path,
-            out folder_created, null, cancellable);
-        
-        if (!folder_created) {
-            int unseen_count = yield remote.fetch_unseen_count_async(folder.path, cancellable);
-            remote_folder.properties.set_status_unseen(unseen_count);
-            yield local.update_folder_status_async(remote_folder, false, cancellable);
+        try {
+            bool folder_created;
+            Imap.Folder remote_folder = yield remote.fetch_folder_async(folder.path,
+                out folder_created, null, cancellable);
+            
+            // if created, don't need to fetch count because it was fetched when it was created
+            int unseen, total;
+            if (!folder_created) {
+                yield remote.fetch_counts_async(folder.path, out unseen, out total, cancellable);
+                remote_folder.properties.set_status_unseen(unseen);
+                remote_folder.properties.set_status_message_count(total, false);
+            } else {
+                unseen = remote_folder.properties.unseen;
+                total = remote_folder.properties.email_total;
+            }
+            
+            yield local.update_folder_status_async(remote_folder, false, true, cancellable);
+        } finally {
+            // added when call scheduled (above)
+            in_refresh_unseen.remove(folder);
         }
-        
-        in_refresh_unseen.remove(folder);
     }
     
     private void reschedule_folder_refresh(bool immediate) {
@@ -535,6 +546,9 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.Account {
      * not be reflected in the local database unless there's a separate connection to the server
      * that is notified or detects these changes.
      *
+     * The returned Folder must be opened prior to use and closed once completed.  ''Leaving a
+     * Folder open will cause a connection leak.''
+     *
      * It is not recommended this object be held open long-term, or that its status or notifications
      * be directly written to the database unless you know exactly what you're doing.  ''Caveat
      * implementor.''
@@ -704,7 +718,7 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.Account {
             // always update, openable or not; have the folder update the UID info the next time
             // it's opened
             try {
-                yield local.update_folder_status_async(remote_folder, false, cancellable);
+                yield local.update_folder_status_async(remote_folder, false, false, cancellable);
             } catch (Error update_error) {
                 debug("Unable to update local folder %s with remote properties: %s",
                     remote_folder.to_string(), update_error.message);
diff --git a/src/engine/imap-engine/imap-engine-minimal-folder.vala 
b/src/engine/imap-engine/imap-engine-minimal-folder.vala
index 4d784f2..c117fc8 100644
--- a/src/engine/imap-engine/imap-engine-minimal-folder.vala
+++ b/src/engine/imap-engine/imap-engine-minimal-folder.vala
@@ -45,6 +45,7 @@ private class Geary.ImapEngine.MinimalFolder : Geary.Folder, Geary.FolderSupport
     private bool remote_opened = false;
     private Nonblocking.ReportingSemaphore<bool> remote_semaphore =
         new Nonblocking.ReportingSemaphore<bool>(false);
+    private Nonblocking.Semaphore closed_semaphore = new Nonblocking.Semaphore();
     private ReplayQueue replay_queue;
     private int remote_count = -1;
     private uint open_remote_timer_id = 0;
@@ -52,6 +53,28 @@ private class Geary.ImapEngine.MinimalFolder : Geary.Folder, Geary.FolderSupport
     private Nonblocking.Mutex open_mutex = new Nonblocking.Mutex();
     private Nonblocking.Mutex close_mutex = new Nonblocking.Mutex();
     
+    /**
+     * Called when the folder is closing (and not reestablishing a connection) and will be flushing
+     * the replay queue.  Subscribers may add ReplayOperations to the list, which will be enqueued
+     * before the queue is flushed.
+     *
+     * Note that this is ''not'' fired if the queue is not being flushed.
+     */
+    public signal void closing(Gee.List<ReplayOperation> final_ops);
+    
+    /**
+     * Fired when an { link EmailIdentifier} that was marked for removal is actually reported as
+     * removed (expunged) from the server.
+     *
+     * Marked messages are reported as removed when marked in the database, to make the operation
+     * appear speedy to the caller.  When the message is finally acknowledged as removed by the
+     * server, "email-removed" is not fired to avoid double-reporting.
+     *
+     * Some internal code (especially Revokables) mark messages for removal but delay the network
+     * operation.  They need to know if the message is removed by another client, however.
+     */
+    public signal void marked_email_removed(Gee.Collection<Geary.EmailIdentifier> removed);
+    
     public MinimalFolder(GenericAccount account, Imap.Account remote, ImapDB.Account local,
         ImapDB.Folder local_folder, SpecialFolderType special_folder_type) {
         _account = account;
@@ -79,6 +102,10 @@ private class Geary.ImapEngine.MinimalFolder : Geary.Folder, Geary.FolderSupport
         local_folder.email_complete.disconnect(on_email_complete);
     }
     
+    protected virtual void notify_closing(Gee.List<ReplayOperation> final_ops) {
+        closing(final_ops);
+    }
+    
     /*
      * These signal notifiers are marked public (note this is a private class) so the various
      * ReplayOperations can directly fire the associated signals while within the queue.
@@ -524,6 +551,9 @@ private class Geary.ImapEngine.MinimalFolder : Geary.Folder, Geary.FolderSupport
         // reset to force waiting in wait_for_open_async()
         remote_semaphore.reset();
         
+        // reset to force waiting in wait_for_close_async()
+        closed_semaphore.reset();
+        
         // Unless NO_DELAY is set, do NOT open the remote side here; wait for the ReplayQueue to
         // require a remote connection or wait_for_open_async() to be called ... this allows for
         // fast local-only operations to occur, local-only either because (a) the folder has all
@@ -745,6 +775,22 @@ private class Geary.ImapEngine.MinimalFolder : Geary.Folder, Geary.FolderSupport
     }
     
     public override async void close_async(Cancellable? cancellable = null) throws Error {
+        // Check open_count but only decrement inside of replay queue
+        if (open_count <= 0)
+            return;
+        
+        UserClose user_close = new UserClose(this, cancellable);
+        replay_queue.schedule(user_close);
+        
+        yield user_close.wait_for_ready_async(cancellable);
+    }
+    
+    public override async void wait_for_close_async(Cancellable? cancellable = null) throws Error {
+        yield closed_semaphore.wait_async(cancellable);
+    }
+    
+    internal async void user_close_async(Cancellable? cancellable) {
+        // decrement open_count and, if zero, continue closing Folder
         if (open_count == 0 || --open_count > 0)
             return;
         
@@ -754,11 +800,9 @@ private class Geary.ImapEngine.MinimalFolder : Geary.Folder, Geary.FolderSupport
         // block anyone from wait_until_open_async(), as this is no longer open
         remote_semaphore.reset();
         
-        ReplayDisconnect disconnect_op = new ReplayDisconnect(this,
-            Imap.ClientSession.DisconnectReason.REMOTE_CLOSE, true, cancellable);
-        replay_queue.schedule(disconnect_op);
-        
-        yield disconnect_op.wait_for_ready_async(cancellable);
+        // don't yield here, close_internal_async() needs to be called outside of the replay queue
+        // the open_count protects against this path scheduling it more than once
+        close_internal_async.begin(CloseReason.LOCAL_CLOSE, CloseReason.REMOTE_CLOSE, true, cancellable);
     }
     
     // Close the remote connection and, if open_count is zero, the Folder itself.  A Mutex is used
@@ -814,6 +858,16 @@ private class Geary.ImapEngine.MinimalFolder : Geary.Folder, Geary.FolderSupport
         // That said, only flush, close, and destroy the ReplayQueue if fully closing and not
         // preparing for a connection reestablishment
         if (open_count <= 0) {
+            // if closing and flushing the queue, give Revokables a chance to schedule their
+            // commit operations before going down
+            if (flush_pending) {
+                Gee.List<ReplayOperation> final_ops = new Gee.ArrayList<ReplayOperation>();
+                notify_closing(final_ops);
+                
+                foreach (ReplayOperation op in final_ops)
+                    replay_queue.schedule(op);
+            }
+            
             // Close the replay queues; if a "clean" close, flush pending operations so everything
             // gets a chance to run; if forced close, drop everything outstanding
             try {
@@ -878,6 +932,10 @@ private class Geary.ImapEngine.MinimalFolder : Geary.Folder, Geary.FolderSupport
         
         notify_closed(CloseReason.FOLDER_CLOSED);
         
+        // If not closing in the background, do it here
+        if (closing_remote_folder == null)
+            closed_semaphore.blind_notify();
+        
         debug("Folder %s closed", to_string());
     }
     
@@ -926,6 +984,10 @@ private class Geary.ImapEngine.MinimalFolder : Geary.Folder, Geary.FolderSupport
         if (folder.open_count <= 0) {
             debug("Not reestablishing connection to %s: closed", folder.to_string());
             
+            // need to do it here if not done in close_internal_locked_async()
+            if (remote_folder != null)
+                folder.closed_semaphore.blind_notify();
+            
             return;
         }
         
@@ -1190,9 +1252,15 @@ private class Geary.ImapEngine.MinimalFolder : Geary.Folder, Geary.FolderSupport
                 err.message);
         }
         
-        // notify of change
-        if (!marked && owned_id != null)
-            notify_email_removed(Geary.iterate<Geary.EmailIdentifier>(owned_id).to_array_list());
+        // notify of change ... use "marked-email-removed" for marked email to allow internal code
+        // to be notified when a removed email is "really" removed
+        if (owned_id != null) {
+            Gee.List<EmailIdentifier> removed = 
Geary.iterate<Geary.EmailIdentifier>(owned_id).to_array_list();
+            if (!marked)
+                notify_email_removed(removed);
+            else
+                marked_email_removed(removed);
+        }
         
         if (!marked)
             notify_email_count_changed(reported_remote_count, CountChangeReason.REMOVED);
@@ -1372,19 +1440,36 @@ private class Geary.ImapEngine.MinimalFolder : Geary.Folder, Geary.FolderSupport
         return copy.destination_uids.size > 0 ? copy.destination_uids : null;
     }
 
-    public virtual async void move_email_async(Gee.List<Geary.EmailIdentifier> to_move,
+    public virtual async Geary.Revokable? move_email_async(Gee.List<Geary.EmailIdentifier> to_move,
         Geary.FolderPath destination, Cancellable? cancellable = null) throws Error {
         check_open("move_email_async");
         check_ids("move_email_async", to_move);
         
         // watch for moving to this folder, which is treated as a no-op
         if (destination.equal_to(path))
-            return;
+            return null;
+        
+        MoveEmailPrepare prepare = new MoveEmailPrepare(this, (Gee.List<ImapDB.EmailIdentifier>) to_move,
+            cancellable);
+        replay_queue.schedule(prepare);
         
-        MoveEmail move = new MoveEmail(this, (Gee.List<ImapDB.EmailIdentifier>) to_move, destination);
-        replay_queue.schedule(move);
+        yield prepare.wait_for_ready_async(cancellable);
         
-        yield move.wait_for_ready_async(cancellable);
+        if (prepare.prepared_for_move == null || prepare.prepared_for_move.size == 0)
+            return null;
+        
+        return new RevokableMove(_account, this, destination, prepare.prepared_for_move);
+    }
+    
+    public void schedule_op(ReplayOperation op) throws Error {
+        check_open("schedule_op");
+        
+        replay_queue.schedule(op);
+    }
+    
+    public async void exec_op_async(ReplayOperation op, Cancellable? cancellable) throws Error {
+        schedule_op(op);
+        yield op.wait_for_ready_async(cancellable);
     }
     
     private void on_email_flags_changed(Gee.Map<Geary.EmailIdentifier, Geary.EmailFlags> changed) {
diff --git a/src/engine/imap-engine/imap-engine-replay-queue.vala 
b/src/engine/imap-engine/imap-engine-replay-queue.vala
index af43bad..a2e3b2b 100644
--- a/src/engine/imap-engine/imap-engine-replay-queue.vala
+++ b/src/engine/imap-engine/imap-engine-replay-queue.vala
@@ -9,6 +9,12 @@ private class Geary.ImapEngine.ReplayQueue : Geary.BaseObject {
     // see as high as 250ms
     private const int NOTIFICATION_QUEUE_WAIT_MSEC = 1000;
     
+    private enum State {
+        OPEN,
+        CLOSING,
+        CLOSED
+    }
+    
     private class CloseReplayQueue : ReplayOperation {
         public CloseReplayQueue() {
             // LOCAL_AND_REMOTE to make sure this operation is flushed all the way down the pipe
@@ -57,8 +63,7 @@ private class Geary.ImapEngine.ReplayQueue : Geary.BaseObject {
     private Gee.ArrayList<ReplayOperation> notification_queue = new Gee.ArrayList<ReplayOperation>();
     private Scheduler.Scheduled? notification_timer = null;
     private int64 next_submission_number = 0;
-    
-    private bool is_closed = false;
+    private State state = State.OPEN;
     
     public virtual signal void scheduled(ReplayOperation op) {
         Logging.debug(Logging.Flag.REPLAY, "[%s] ReplayQueue::scheduled: %s %s", to_string(),
@@ -143,7 +148,7 @@ private class Geary.ImapEngine.ReplayQueue : Geary.BaseObject {
      */
     public bool schedule(ReplayOperation op) {
         // ReplayClose is allowed past the velvet ropes even as the hoi palloi is turned away
-        if (is_closed && !(op is CloseReplayQueue)) {
+        if (state != State.OPEN && !(op is CloseReplayQueue)) {
             debug("Unable to schedule replay operation %s on %s: replay queue closed", op.to_string(),
                 to_string());
             
@@ -188,7 +193,7 @@ private class Geary.ImapEngine.ReplayQueue : Geary.BaseObject {
      * Returns false if the operation was not schedule (queue already closed).
      */
     public bool schedule_server_notification(ReplayOperation op) {
-        if (is_closed) {
+        if (state != State.OPEN) {
             debug("Unable to schedule notification operation %s on %s: replay queue closed", op.to_string(),
                 to_string());
             
@@ -293,7 +298,7 @@ private class Geary.ImapEngine.ReplayQueue : Geary.BaseObject {
      * A ReplayQueue cannot be re-opened.
      */
     public async void close_async(bool flush_pending, Cancellable? cancellable = null) throws Error {
-        if (is_closed)
+        if (state != State.OPEN)
             return;
         
         // cancel notification queue timeout
@@ -306,8 +311,7 @@ private class Geary.ImapEngine.ReplayQueue : Geary.BaseObject {
         
         // mark as closed now to prevent further scheduling ... ReplayClose gets special
         // consideration in schedule()
-        is_closed = true;
-        
+        state = State.CLOSING;
         closing();
         
         // if not flushing pending, clear out all waiting operations, backing out any that need to
@@ -322,6 +326,7 @@ private class Geary.ImapEngine.ReplayQueue : Geary.BaseObject {
         
         yield close_op.wait_for_ready_async(cancellable);
         
+        state = State.CLOSED;
         closed();
     }
     
@@ -472,7 +477,7 @@ private class Geary.ImapEngine.ReplayQueue : Geary.BaseObject {
             
             // wait until the remote folder is opened (or throws an exception, in which case closed)
             try {
-                if (!is_close_op && folder_opened)
+                if (!is_close_op && folder_opened && state == State.OPEN)
                     yield owner.wait_for_open_async();
             } catch (Error remote_err) {
                 debug("Folder %s closed or failed to open, remote replay queue closing: %s",
diff --git a/src/engine/imap-engine/imap-engine-revokable-committed-move.vala 
b/src/engine/imap-engine/imap-engine-revokable-committed-move.vala
new file mode 100644
index 0000000..864672f
--- /dev/null
+++ b/src/engine/imap-engine/imap-engine-revokable-committed-move.vala
@@ -0,0 +1,64 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+/**
+ * A { link Geary.Revokable} for moving email back to its source after committed with
+ * { link RevokableMove}.
+ */
+
+private class Geary.ImapEngine.RevokableCommittedMove : Revokable {
+    private GenericAccount account;
+    private FolderPath source;
+    private FolderPath destination;
+    private Gee.Set<Imap.UID> destination_uids;
+    
+    public RevokableCommittedMove(GenericAccount account, FolderPath source, FolderPath destination,
+        Gee.Set<Imap.UID> destination_uids) {
+        this.account = account;
+        this.source = source;
+        this.destination = destination;
+        this.destination_uids = destination_uids;
+    }
+    
+    protected override async void internal_revoke_async(Cancellable? cancellable) throws Error {
+        Imap.Folder? detached_destination = null;
+        try {
+            // use a detached folder to quickly open, issue command, and leave, without full
+            // normalization that MinimalFolder requires
+            detached_destination = yield account.fetch_detached_folder_async(destination, cancellable);
+            
+            yield detached_destination.open_async(cancellable);
+            
+            foreach (Imap.MessageSet msg_set in Imap.MessageSet.uid_sparse(destination_uids)) {
+                // don't use Cancellable to try to make operations atomic
+                yield detached_destination.copy_email_async(msg_set, source, null);
+                yield detached_destination.remove_email_async(msg_set.to_list(), null);
+                
+                if (cancellable != null && cancellable.is_cancelled())
+                    throw new IOError.CANCELLED("Revoke cancelled");
+            }
+            
+            notify_revoked();
+        } finally {
+            if (detached_destination != null) {
+                try {
+                    yield detached_destination.close_async(cancellable);
+                } catch (Error err) {
+                    // ignored
+                }
+            }
+            
+            valid = false;
+        }
+    }
+    
+    protected override async void internal_commit_async(Cancellable? cancellable) throws Error {
+        // pretty simple: already committed, so done
+        notify_committed(null);
+        valid = false;
+    }
+}
+
diff --git a/src/engine/imap-engine/imap-engine-revokable-move.vala 
b/src/engine/imap-engine/imap-engine-revokable-move.vala
new file mode 100644
index 0000000..cb7f9f8
--- /dev/null
+++ b/src/engine/imap-engine/imap-engine-revokable-move.vala
@@ -0,0 +1,115 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+/**
+ * A @{link Geary.Revokable} for { link MinimalFolder} move operations.
+ *
+ * This will delay executing the move until (a) the source Folder is closed or (b) a timeout passes.
+ * Even then, it will fire its "committed" signal with a { link RevokableCommittedMove} to allow
+ * the user to undo the operation, albeit taking more time to connect, open the destination folder,
+ * and move the mail back.
+ */
+
+private class Geary.ImapEngine.RevokableMove : Revokable {
+    private const int COMMIT_TIMEOUT_SEC = 60;
+    
+    private GenericAccount account;
+    private ImapEngine.MinimalFolder source;
+    private FolderPath destination;
+    private Gee.Set<ImapDB.EmailIdentifier> move_ids;
+    
+    public RevokableMove(GenericAccount account, ImapEngine.MinimalFolder source, FolderPath destination,
+        Gee.Set<ImapDB.EmailIdentifier> move_ids) {
+        base (COMMIT_TIMEOUT_SEC);
+        
+        this.account = account;
+        this.source = source;
+        this.destination = destination;
+        this.move_ids = move_ids;
+        
+        account.folders_available_unavailable.connect(on_folders_available_unavailable);
+        source.email_removed.connect(on_source_email_removed);
+        source.marked_email_removed.connect(on_source_email_removed);
+        source.closing.connect(on_source_closing);
+    }
+    
+    ~RevokableMove() {
+        account.folders_available_unavailable.disconnect(on_folders_available_unavailable);
+        source.email_removed.disconnect(on_source_email_removed);
+        source.marked_email_removed.disconnect(on_source_email_removed);
+        source.closing.disconnect(on_source_closing);
+        
+        // if still valid, schedule operation so its executed
+        if (valid && source.get_open_state() != Folder.OpenState.CLOSED) {
+            debug("Freeing revokable, scheduling move %d emails from %s to %s", move_ids.size,
+                source.path.to_string(), destination.to_string());
+            
+            try {
+                source.schedule_op(new MoveEmailCommit(source, move_ids, destination, null));
+            } catch (Error err) {
+                debug("Move from %s to %s failed: %s", source.path.to_string(), destination.to_string(),
+                    err.message);
+            }
+        }
+    }
+    
+    protected override async void internal_revoke_async(Cancellable? cancellable) throws Error {
+        try {
+            yield source.exec_op_async(new MoveEmailRevoke(source, move_ids, cancellable),
+                cancellable);
+            
+            // valid must still be true before firing
+            notify_revoked();
+        } finally {
+            valid = false;
+        }
+    }
+    
+    protected override async void internal_commit_async(Cancellable? cancellable) throws Error {
+        try {
+            MoveEmailCommit op = new MoveEmailCommit(source, move_ids, destination, cancellable);
+            yield source.exec_op_async(op, cancellable);
+            
+            // valid must still be true before firing
+            notify_committed(new RevokableCommittedMove(account, source.path, destination, 
op.destination_uids));
+        } finally {
+            valid = false;
+        }
+    }
+    
+    private void on_folders_available_unavailable(Gee.List<Folder>? available, Gee.List<Folder>? 
unavailable) {
+        // look for either of the folders going away
+        if (unavailable != null) {
+            foreach (Folder folder in unavailable) {
+                if (folder.path.equal_to(source.path) || folder.path.equal_to(destination)) {
+                    valid = false;
+                    
+                    break;
+                }
+            }
+        }
+    }
+    
+    private void on_source_email_removed(Gee.Collection<EmailIdentifier> ids) {
+        // one-way switch
+        if (!valid)
+            return;
+        
+        foreach (EmailIdentifier id in ids)
+            move_ids.remove((ImapDB.EmailIdentifier) id);
+        
+        valid = move_ids.size > 0;
+    }
+    
+    private void on_source_closing(Gee.List<ReplayOperation> final_ops) {
+        if (!valid)
+            return;
+        
+        final_ops.add(new MoveEmailCommit(source, move_ids, destination, null));
+        valid = false;
+    }
+}
+
diff --git a/src/engine/imap-engine/imap-engine-send-replay-operation.vala 
b/src/engine/imap-engine/imap-engine-send-replay-operation.vala
index 5ae0dce..50449ce 100644
--- a/src/engine/imap-engine/imap-engine-send-replay-operation.vala
+++ b/src/engine/imap-engine/imap-engine-send-replay-operation.vala
@@ -5,11 +5,15 @@
  */
 
 private abstract class Geary.ImapEngine.SendReplayOperation : Geary.ImapEngine.ReplayOperation {
-    public SendReplayOperation(string name, ReplayOperation.OnError on_remote_error = OnError.THROW) {
+    protected SendReplayOperation(string name, ReplayOperation.OnError on_remote_error = OnError.THROW) {
         base (name, ReplayOperation.Scope.LOCAL_AND_REMOTE, on_remote_error);
     }
     
-    public SendReplayOperation.only_remote(string name, ReplayOperation.OnError on_remote_error = 
OnError.THROW) {
+    protected SendReplayOperation.only_local(string name, ReplayOperation.OnError on_remote_error = 
OnError.THROW) {
+        base (name, ReplayOperation.Scope.LOCAL_ONLY, on_remote_error);
+    }
+    
+    protected SendReplayOperation.only_remote(string name, ReplayOperation.OnError on_remote_error = 
OnError.THROW) {
         base (name, ReplayOperation.Scope.REMOTE_ONLY, on_remote_error);
     }
     
diff --git a/src/engine/imap-engine/replay-ops/imap-engine-move-email.vala 
b/src/engine/imap-engine/replay-ops/imap-engine-move-email-commit.vala
similarity index 57%
rename from src/engine/imap-engine/replay-ops/imap-engine-move-email.vala
rename to src/engine/imap-engine/replay-ops/imap-engine-move-email-commit.vala
index d0ae775..d0de4d5 100644
--- a/src/engine/imap-engine/replay-ops/imap-engine-move-email.vala
+++ b/src/engine/imap-engine/replay-ops/imap-engine-move-email-commit.vala
@@ -4,68 +4,49 @@
  * (version 2.1 or later).  See the COPYING file in this distribution.
  */
 
-private class Geary.ImapEngine.MoveEmail : Geary.ImapEngine.SendReplayOperation {
+/**
+ * Stage two of a { link RevokableMove}: move messages from folder to destination.
+ */
+
+private class Geary.ImapEngine.MoveEmailCommit : Geary.ImapEngine.SendReplayOperation {
+    public Gee.Set<Imap.UID> destination_uids = new Gee.HashSet<Imap.UID>();
+    
     private MinimalFolder engine;
     private Gee.List<ImapDB.EmailIdentifier> to_move = new Gee.ArrayList<ImapDB.EmailIdentifier>();
     private Geary.FolderPath destination;
     private Cancellable? cancellable;
-    private Gee.Set<ImapDB.EmailIdentifier>? moved_ids = null;
-    private int original_count = 0;
     private Gee.List<Imap.MessageSet>? remaining_msg_sets = null;
-
-    public MoveEmail(MinimalFolder engine, Gee.List<ImapDB.EmailIdentifier> to_move, 
-        Geary.FolderPath destination, Cancellable? cancellable = null) {
-        base("MoveEmail", OnError.RETRY);
-
+    
+    public MoveEmailCommit(MinimalFolder engine, Gee.Collection<ImapDB.EmailIdentifier> to_move,
+        Geary.FolderPath destination, Cancellable? cancellable) {
+        base.only_remote("MoveEmailCommit", OnError.RETRY);
+        
         this.engine = engine;
-
+        
         this.to_move.add_all(to_move);
         this.destination = destination;
         this.cancellable = cancellable;
     }
     
     public override void notify_remote_removed_ids(Gee.Collection<ImapDB.EmailIdentifier> ids) {
-        // don't bother updating on server or backing out locally
-        if (moved_ids != null)
-            moved_ids.remove_all(ids);
+        to_move.remove_all(ids);
     }
     
     public override async ReplayOperation.Status replay_local_async() throws Error {
-        if (to_move.size <= 0)
-            return ReplayOperation.Status.COMPLETED;
-        
-        int remote_count;
-        int last_seen_remote_count;
-        original_count = engine.get_remote_counts(out remote_count, out last_seen_remote_count);
-        
-        // as this value is only used for reporting, offer best-possible service
-        if (original_count < 0)
-            original_count = to_move.size;
-        
-        moved_ids = yield engine.local_folder.mark_removed_async(to_move, true, cancellable);
-        if (moved_ids == null || moved_ids.size == 0)
-            return ReplayOperation.Status.COMPLETED;
-        
-        engine.replay_notify_email_removed(moved_ids);
-        
-        engine.replay_notify_email_count_changed(Numeric.int_floor(original_count - to_move.size, 0),
-            Geary.Folder.CountChangeReason.REMOVED);
-        
         return ReplayOperation.Status.CONTINUE;
     }
     
     public override void get_ids_to_be_remote_removed(Gee.Collection<ImapDB.EmailIdentifier> ids) {
-        if (moved_ids != null)
-            ids.add_all(moved_ids);
+        ids.add_all(to_move);
     }
     
     public override async ReplayOperation.Status replay_remote_async() throws Error {
-        if (moved_ids.size == 0)
+        if (to_move.size == 0)
             return ReplayOperation.Status.COMPLETED;
         
         // Remaining MessageSets are persisted in case of network retries
         if (remaining_msg_sets == null)
-            remaining_msg_sets = Imap.MessageSet.uid_sparse(ImapDB.EmailIdentifier.to_uids(moved_ids));
+            remaining_msg_sets = Imap.MessageSet.uid_sparse(ImapDB.EmailIdentifier.to_uids(to_move));
         
         if (remaining_msg_sets == null || remaining_msg_sets.size == 0)
             return ReplayOperation.Status.COMPLETED;
@@ -78,7 +59,12 @@ private class Geary.ImapEngine.MoveEmail : Geary.ImapEngine.SendReplayOperation
                 throw new IOError.CANCELLED("Move email to %s cancelled", engine.remote_folder.to_string());
             
             Imap.MessageSet msg_set = iter.get();
-            yield engine.remote_folder.copy_email_async(msg_set, destination, null);
+            
+            Gee.Map<Imap.UID, Imap.UID>? map = yield engine.remote_folder.copy_email_async(msg_set,
+                destination, null);
+            if (map != null)
+                destination_uids.add_all(map.values);
+            
             yield engine.remote_folder.remove_email_async(msg_set.to_list(), null);
             
             // completed successfully, remove from list in case of retry
@@ -87,14 +73,19 @@ private class Geary.ImapEngine.MoveEmail : Geary.ImapEngine.SendReplayOperation
         
         return ReplayOperation.Status.COMPLETED;
     }
-
+    
     public override async void backout_local_async() throws Error {
-        yield engine.local_folder.mark_removed_async(moved_ids, false, cancellable);
+        if (to_move.size == 0)
+            return;
+        
+        yield engine.local_folder.mark_removed_async(to_move, false, cancellable);
         
-        engine.replay_notify_email_inserted(moved_ids);
-        engine.replay_notify_email_count_changed(original_count, Geary.Folder.CountChangeReason.INSERTED);
+        int count = engine.get_remote_counts(null, null);
+        
+        engine.replay_notify_email_inserted(to_move);
+        engine.replay_notify_email_count_changed(count + to_move.size, Folder.CountChangeReason.INSERTED);
     }
-
+    
     public override string describe_state() {
         return "%d email IDs to %s".printf(to_move.size, destination.to_string());
     }
diff --git a/src/engine/imap-engine/replay-ops/imap-engine-move-email-prepare.vala 
b/src/engine/imap-engine/replay-ops/imap-engine-move-email-prepare.vala
new file mode 100644
index 0000000..58b4e4a
--- /dev/null
+++ b/src/engine/imap-engine/replay-ops/imap-engine-move-email-prepare.vala
@@ -0,0 +1,70 @@
+/* Copyright 2012-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+/**
+ * Stage one of a { link RevokableMove}: collect valid { link ImapDB.EmailIdentifiers}, mark
+ * messages as removed, and update counts.
+ */
+
+private class Geary.ImapEngine.MoveEmailPrepare : Geary.ImapEngine.SendReplayOperation {
+    public Gee.Set<ImapDB.EmailIdentifier>? prepared_for_move = null;
+    
+    private MinimalFolder engine;
+    private Cancellable? cancellable;
+    private Gee.List<ImapDB.EmailIdentifier> to_move = new Gee.ArrayList<ImapDB.EmailIdentifier>();
+    
+    public MoveEmailPrepare(MinimalFolder engine, Gee.Collection<ImapDB.EmailIdentifier> to_move,
+        Cancellable? cancellable) {
+        base.only_local("MoveEmailPrepare", OnError.RETRY);
+        
+        this.engine = engine;
+        this.to_move.add_all(to_move);
+        this.cancellable = cancellable;
+    }
+    
+    public override void notify_remote_removed_ids(Gee.Collection<ImapDB.EmailIdentifier> ids) {
+        if (prepared_for_move != null)
+            prepared_for_move.remove_all(ids);
+    }
+    
+    public override async ReplayOperation.Status replay_local_async() throws Error {
+        if (to_move.size <= 0)
+            return ReplayOperation.Status.COMPLETED;
+        
+        int count = engine.get_remote_counts(null, null);
+        
+        // as this value is only used for reporting, offer best-possible service
+        if (count < 0)
+            count = to_move.size;
+        
+        prepared_for_move = yield engine.local_folder.mark_removed_async(to_move, true, cancellable);
+        if (prepared_for_move == null || prepared_for_move.size == 0)
+            return ReplayOperation.Status.COMPLETED;
+        
+        engine.replay_notify_email_removed(prepared_for_move);
+        
+        engine.replay_notify_email_count_changed(
+            Numeric.int_floor(count - prepared_for_move.size, 0),
+            Folder.CountChangeReason.REMOVED);
+        
+        return ReplayOperation.Status.COMPLETED;
+    }
+    
+    public override void get_ids_to_be_remote_removed(Gee.Collection<ImapDB.EmailIdentifier> ids) {
+    }
+    
+    public override async ReplayOperation.Status replay_remote_async() throws Error {
+        return ReplayOperation.Status.COMPLETED;
+    }
+    
+    public override async void backout_local_async() throws Error {
+    }
+    
+    public override string describe_state() {
+        return "%d email IDs".printf(prepared_for_move != null ? prepared_for_move.size : 0);
+    }
+}
+
diff --git a/src/engine/imap-engine/replay-ops/imap-engine-move-email-revoke.vala 
b/src/engine/imap-engine/replay-ops/imap-engine-move-email-revoke.vala
new file mode 100644
index 0000000..15de189
--- /dev/null
+++ b/src/engine/imap-engine/replay-ops/imap-engine-move-email-revoke.vala
@@ -0,0 +1,62 @@
+/* Copyright 2012-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+/**
+ * Revoked { link RevokableMove}: Unmark emails as removed and update counts.
+ */
+
+private class Geary.ImapEngine.MoveEmailRevoke : Geary.ImapEngine.SendReplayOperation {
+    private MinimalFolder engine;
+    private Gee.List<ImapDB.EmailIdentifier> to_revoke = new Gee.ArrayList<ImapDB.EmailIdentifier>();
+    private Cancellable? cancellable;
+    
+    public MoveEmailRevoke(MinimalFolder engine, Gee.Collection<ImapDB.EmailIdentifier> to_revoke,
+        Cancellable? cancellable) {
+        base.only_local("MoveEmailRevoke", OnError.RETRY);
+        
+        this.engine = engine;
+        
+        this.to_revoke.add_all(to_revoke);
+        this.cancellable = cancellable;
+    }
+    
+    public override void notify_remote_removed_ids(Gee.Collection<ImapDB.EmailIdentifier> ids) {
+        to_revoke.remove_all(ids);
+    }
+    
+    public override async ReplayOperation.Status replay_local_async() throws Error {
+        if (to_revoke.size == 0)
+            return ReplayOperation.Status.COMPLETED;
+        
+        Gee.Set<ImapDB.EmailIdentifier>? revoked = yield engine.local_folder.mark_removed_async(
+            to_revoke, false, cancellable);
+        if (revoked == null || revoked.size == 0)
+            return ReplayOperation.Status.COMPLETED;
+        
+        int count = engine.get_remote_counts(null, null);
+        
+        engine.replay_notify_email_inserted(revoked);
+        engine.replay_notify_email_count_changed(count + revoked.size,
+            Geary.Folder.CountChangeReason.INSERTED);
+        
+        return ReplayOperation.Status.COMPLETED;
+    }
+    
+    public override void get_ids_to_be_remote_removed(Gee.Collection<ImapDB.EmailIdentifier> ids) {
+    }
+    
+    public override async ReplayOperation.Status replay_remote_async() throws Error {
+        return ReplayOperation.Status.COMPLETED;
+    }
+    
+    public override async void backout_local_async() throws Error {
+    }
+    
+    public override string describe_state() {
+        return "%d email IDs".printf(to_revoke.size);
+    }
+}
+
diff --git a/src/engine/imap-engine/replay-ops/imap-engine-user-close.vala 
b/src/engine/imap-engine/replay-ops/imap-engine-user-close.vala
new file mode 100644
index 0000000..14e279f
--- /dev/null
+++ b/src/engine/imap-engine/replay-ops/imap-engine-user-close.vala
@@ -0,0 +1,45 @@
+/* Copyright 2012-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+private class Geary.ImapEngine.UserClose : Geary.ImapEngine.ReplayOperation {
+    private MinimalFolder owner;
+    private Cancellable? cancellable;
+    
+    public UserClose(MinimalFolder owner, Cancellable? cancellable) {
+        base ("UserClose", Scope.LOCAL_ONLY);
+        
+        this.owner = owner;
+        this.cancellable = cancellable;
+    }
+    
+    public override void notify_remote_removed_position(Imap.SequenceNumber removed) {
+    }
+    
+    public override void notify_remote_removed_ids(Gee.Collection<ImapDB.EmailIdentifier> ids) {
+    }
+    
+    public override void get_ids_to_be_remote_removed(Gee.Collection<ImapDB.EmailIdentifier> ids) {
+    }
+    
+    public override async ReplayOperation.Status replay_local_async() throws Error {
+        yield owner.user_close_async(cancellable);
+        
+        return ReplayOperation.Status.COMPLETED;
+    }
+    
+    public override async void backout_local_async() throws Error {
+    }
+    
+    public override async ReplayOperation.Status replay_remote_async() throws Error {
+        // should not be called
+        return ReplayOperation.Status.COMPLETED;
+    }
+    
+    public override string describe_state() {
+        return "";
+    }
+}
+
diff --git a/src/engine/imap/api/imap-account.vala b/src/engine/imap/api/imap-account.vala
index 42bbf71..3a0a7f6 100644
--- a/src/engine/imap/api/imap-account.vala
+++ b/src/engine/imap/api/imap-account.vala
@@ -308,8 +308,8 @@ private class Geary.Imap.Account : BaseObject {
         }
     }
     
-    public async int fetch_unseen_count_async(FolderPath path, Cancellable? cancellable)
-        throws Error {
+    public async void fetch_counts_async(FolderPath path, out int unseen, out int total,
+        Cancellable? cancellable) throws Error {
         check_open();
         
         MailboxInformation? mailbox_info = path_to_mailbox.get(path);
@@ -320,8 +320,10 @@ private class Geary.Imap.Account : BaseObject {
                 path.to_string());
         }
         
-        StatusData data = yield fetch_status_async(path, { StatusDataType.UNSEEN }, cancellable);
-        return data.unseen;
+        StatusData data = yield fetch_status_async(path, { StatusDataType.UNSEEN, StatusDataType.MESSAGES },
+            cancellable);
+        unseen = data.unseen;
+        total = data.messages;
     }
     
     private async StatusData fetch_status_async(FolderPath path, StatusDataType[] status_types,
diff --git a/src/engine/imap/transport/imap-client-session-manager.vala 
b/src/engine/imap/transport/imap-client-session-manager.vala
index c84ccce..e11ac2e 100644
--- a/src/engine/imap/transport/imap-client-session-manager.vala
+++ b/src/engine/imap/transport/imap-client-session-manager.vala
@@ -137,10 +137,15 @@ public class Geary.Imap.ClientSessionManager : BaseObject {
         
         // TODO: This isn't the best (deterministic) way to deal with this, but it's easy and works
         // for now
+        int attempts = 0;
         while (sessions.size > 0) {
             debug("Waiting for ClientSessions to disconnect from ClientSessionManager...");
             Timeout.add(250, close_async.callback);
             yield;
+            
+            // give up after three seconds
+            if (++attempts > 12)
+                break;
         }
     }
     
@@ -341,7 +346,8 @@ public class Geary.Imap.ClientSessionManager : BaseObject {
     
     public async void release_session_async(ClientSession session, Cancellable? cancellable)
         throws Error {
-        check_open();
+        // Don't check_open(), it's valid for this to be called when is_open is false, that happens
+        // during mop-up
         
         MailboxSpecifier? mailbox;
         ClientSession.Context context = session.get_context(out mailbox);
@@ -383,9 +389,15 @@ public class Geary.Imap.ClientSessionManager : BaseObject {
                 assert_not_reached();
         }
         
-        if (unreserve) {
+        if (!unreserve)
+            return;
+        
+        // if not open, disconnect, which will remove from the reserved pool anyway
+        if (!is_open) {
+            yield force_disconnect_async(session, true);
+        } else {
             try {
-                // don't respect Cancellable because this *must* happen; don't want this lingering 
+                // don't respect Cancellable because this *must* happen; don't want this lingering
                 // on the reserved list forever
                 int token = yield sessions_mutex.claim_async();
                 


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