[geary] Support true delete from all Gmail folders: Bug #721790



commit 0ef2113ef239ee538bfd5e8866456e785e67722d
Author: Jim Nelson <jim yorba org>
Date:   Fri Jan 9 15:09:53 2015 -0800

    Support true delete from all Gmail folders: Bug #721790
    
    Geary now offers "true" deletion of messages in Gmail.  It does this
    by moving the offending email to the Trash folder and then expunging
    them from the Trash.
    
    Fortunately, this works with the composer without changes, meaning
    discarded drafts won't sit in the Trash for 30 days.

 src/CMakeLists.txt                                 |    3 +
 .../gmail/imap-engine-gmail-account.vala           |    7 +-
 .../gmail/imap-engine-gmail-all-mail-folder.vala   |   21 ++++
 .../gmail/imap-engine-gmail-drafts-folder.vala     |   29 +++++
 .../gmail/imap-engine-gmail-folder.vala            |   55 +++++++++-
 .../gmail/imap-engine-gmail-spam-trash-folder.vala |   23 ++++
 .../imap-engine/imap-engine-generic-account.vala   |   25 ++++
 .../imap-engine/imap-engine-minimal-folder.vala    |   19 +++-
 .../replay-ops/imap-engine-copy-email.vala         |   10 ++-
 .../replay-ops/imap-engine-create-email.vala       |    2 +-
 .../replay-ops/imap-engine-move-email.vala         |    2 +-
 .../replay-ops/imap-engine-remove-email.vala       |    3 +-
 src/engine/imap/api/imap-account.vala              |   31 +++++-
 src/engine/imap/api/imap-folder.vala               |   64 +++++++++--
 src/engine/imap/command/imap-message-set.vala      |  124 ++++++++++++++++++++
 src/engine/imap/parameter/imap-list-parameter.vala |   38 ++++++
 .../imap/parameter/imap-number-parameter.vala      |    8 +-
 .../imap/parameter/imap-string-parameter.vala      |   18 +++
 .../imap/response/imap-response-code-type.vala     |    1 +
 src/engine/imap/response/imap-response-code.vala   |   20 +++
 src/engine/imap/transport/imap-deserializer.vala   |    2 +
 src/engine/util/util-collection.vala               |    4 +
 22 files changed, 485 insertions(+), 24 deletions(-)
---
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 251b3b0..64fb8d6 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -193,8 +193,11 @@ engine/imap-engine/imap-engine-replay-operation.vala
 engine/imap-engine/imap-engine-replay-queue.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
+engine/imap-engine/gmail/imap-engine-gmail-drafts-folder.vala
 engine/imap-engine/gmail/imap-engine-gmail-folder.vala
 engine/imap-engine/gmail/imap-engine-gmail-search-folder.vala
+engine/imap-engine/gmail/imap-engine-gmail-spam-trash-folder.vala
 engine/imap-engine/other/imap-engine-other-account.vala
 engine/imap-engine/other/imap-engine-other-folder.vala
 engine/imap-engine/outlook/imap-engine-outlook-account.vala
diff --git a/src/engine/imap-engine/gmail/imap-engine-gmail-account.vala 
b/src/engine/imap-engine/gmail/imap-engine-gmail-account.vala
index d409efc..7e4ba31 100644
--- a/src/engine/imap-engine/gmail/imap-engine-gmail-account.vala
+++ b/src/engine/imap-engine/gmail/imap-engine-gmail-account.vala
@@ -36,13 +36,16 @@ private class Geary.ImapEngine.GmailAccount : Geary.ImapEngine.GenericAccount {
         
         switch (special_folder_type) {
             case SpecialFolderType.ALL_MAIL:
-                return new MinimalFolder(this, remote_account, local_account, local_folder,
+                return new GmailAllMailFolder(this, remote_account, local_account, local_folder,
                     special_folder_type);
             
             case SpecialFolderType.DRAFTS:
+                return new GmailDraftsFolder(this, remote_account, local_account, local_folder,
+                    special_folder_type);
+            
             case SpecialFolderType.SPAM:
             case SpecialFolderType.TRASH:
-                return new GenericFolder(this, remote_account, local_account, local_folder,
+                return new GmailSpamTrashFolder(this, remote_account, local_account, local_folder,
                     special_folder_type);
             
             default:
diff --git a/src/engine/imap-engine/gmail/imap-engine-gmail-all-mail-folder.vala 
b/src/engine/imap-engine/gmail/imap-engine-gmail-all-mail-folder.vala
new file mode 100644
index 0000000..ce46e75
--- /dev/null
+++ b/src/engine/imap-engine/gmail/imap-engine-gmail-all-mail-folder.vala
@@ -0,0 +1,21 @@
+/* Copyright 2015 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.
+ */
+
+/**
+ * Gmail's All Mail folder supports basic operations as well as true removal of emails.
+ */
+
+private class Geary.ImapEngine.GmailAllMailFolder : MinimalFolder, FolderSupport.Remove {
+    public GmailAllMailFolder(GmailAccount account, Imap.Account remote, ImapDB.Account local,
+        ImapDB.Folder local_folder, SpecialFolderType special_folder_type) {
+        base (account, remote, local, local_folder, special_folder_type);
+    }
+    
+    public async void remove_email_async(Gee.List<Geary.EmailIdentifier> email_ids,
+        Cancellable? cancellable = null) throws Error {
+        yield GmailFolder.true_remove_email_async(this, email_ids, cancellable);
+    }
+}
diff --git a/src/engine/imap-engine/gmail/imap-engine-gmail-drafts-folder.vala 
b/src/engine/imap-engine/gmail/imap-engine-gmail-drafts-folder.vala
new file mode 100644
index 0000000..57ea1df
--- /dev/null
+++ b/src/engine/imap-engine/gmail/imap-engine-gmail-drafts-folder.vala
@@ -0,0 +1,29 @@
+/* Copyright 2015 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.
+ */
+
+/**
+ * Gmail's Drafts folder supports basic operations as well as true removal of messages and creating
+ * new ones (IMAP APPEND).
+ */
+
+private class Geary.ImapEngine.GmailDraftsFolder : MinimalFolder, FolderSupport.Create,
+    FolderSupport.Remove {
+    public GmailDraftsFolder(GmailAccount account, Imap.Account remote, ImapDB.Account local,
+        ImapDB.Folder local_folder, SpecialFolderType special_folder_type) {
+        base (account, remote, local, local_folder, special_folder_type);
+    }
+    
+    public new async Geary.EmailIdentifier? create_email_async(
+        RFC822.Message rfc822, Geary.EmailFlags? flags, DateTime? date_received,
+        Geary.EmailIdentifier? id, Cancellable? cancellable = null) throws Error {
+        return yield base.create_email_async(rfc822, flags, date_received, id, cancellable);
+    }
+    
+    public async void remove_email_async(Gee.List<Geary.EmailIdentifier> email_ids,
+        Cancellable? cancellable = null) throws Error {
+        yield GmailFolder.true_remove_email_async(this, email_ids, cancellable);
+    }
+}
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 e8839c0..16e5529 100644
--- a/src/engine/imap-engine/gmail/imap-engine-gmail-folder.vala
+++ b/src/engine/imap-engine/gmail/imap-engine-gmail-folder.vala
@@ -5,7 +5,7 @@
  */
 
 private class Geary.ImapEngine.GmailFolder : MinimalFolder, FolderSupport.Archive,
-    FolderSupport.Create {
+    FolderSupport.Create, FolderSupport.Remove {
     public GmailFolder(GmailAccount account, Imap.Account remote, ImapDB.Account local,
         ImapDB.Folder local_folder, SpecialFolderType special_folder_type) {
         base (account, remote, local, local_folder, special_folder_type);
@@ -21,5 +21,58 @@ private class Geary.ImapEngine.GmailFolder : MinimalFolder, FolderSupport.Archiv
         Cancellable? cancellable = null) throws Error {
         yield expunge_email_async(email_ids, cancellable);
     }
+    
+    public async void remove_email_async(Gee.List<Geary.EmailIdentifier> email_ids,
+        Cancellable? cancellable = null) throws Error {
+        yield true_remove_email_async(this, email_ids, cancellable);
+    }
+    
+    /**
+     * Truly removes an email from Gmail by moving it to the Trash and then deleting it from the
+     * Trash.
+     *
+     * TODO: Because the steps after copy don't go through the ReplayQueue, they won't be recorded
+     * in the database directly.  This is important when/if offline mode is coded, as if there's
+     * no connection (or the connection dies) there's no record that Geary needs to perform the
+     * final remove when a connection is reestablished.
+     */
+    public static async void true_remove_email_async(MinimalFolder folder,
+        Gee.List<Geary.EmailIdentifier> email_ids, Cancellable? cancellable) throws Error {
+        // Get path to Trash folder
+        Geary.Folder? trash = folder.account.get_special_folder(SpecialFolderType.TRASH);
+        if (trash == null)
+            throw new EngineError.NOT_FOUND("%s: Trash folder not found for removal", folder.to_string());
+        
+        // Copy to Trash, collect UIDs (note that copying to Trash is like a move; the copied
+        // messages are removed from all labels)
+        Gee.Set<Imap.UID>? uids = yield folder.copy_email_uids_async(email_ids, trash.path, cancellable);
+        if (uids == null || uids.size == 0) {
+            debug("%s: Can't true-remove %d emails, no COPYUIDs returned", folder.to_string(),
+                email_ids.size);
+            
+            return;
+        }
+        
+        // For speed reasons, use a detached Imap.Folder object to delete moved emails; this is a
+        // separate connection and is not synchronized with the database, but also avoids a full
+        // folder normalization, which can be a heavyweight operation
+        Imap.Folder imap_trash = yield ((GenericAccount) folder.account).fetch_detached_folder_async(
+            trash.path, cancellable);
+        
+        yield imap_trash.open_async(cancellable);
+        try {
+            yield imap_trash.remove_email_async(Imap.MessageSet.uid_sparse(uids), cancellable);
+        } finally {
+            try {
+                // don't use cancellable, need to close this connection no matter what
+                yield imap_trash.close_async(null);
+            } catch (Error err) {
+                // ignored
+            }
+        }
+        
+        debug("%s: Successfully true-removed %d/%d emails", folder.to_string(), uids.size,
+            email_ids.size);
+    }
 }
 
diff --git a/src/engine/imap-engine/gmail/imap-engine-gmail-spam-trash-folder.vala 
b/src/engine/imap-engine/gmail/imap-engine-gmail-spam-trash-folder.vala
new file mode 100644
index 0000000..8583eaf
--- /dev/null
+++ b/src/engine/imap-engine/gmail/imap-engine-gmail-spam-trash-folder.vala
@@ -0,0 +1,23 @@
+/* Copyright 2015 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.
+ */
+
+/**
+ * Gmail's Spam and Trash folders support basic operations and removing messages with a traditional
+ * IMAP STORE/EXPUNGE operation.
+ */
+
+private class Geary.ImapEngine.GmailSpamTrashFolder : MinimalFolder, FolderSupport.Remove {
+    public GmailSpamTrashFolder(GmailAccount account, Imap.Account remote, ImapDB.Account local,
+        ImapDB.Folder local_folder, SpecialFolderType special_folder_type) {
+        base (account, remote, local, local_folder, special_folder_type);
+    }
+    
+    public async void remove_email_async(Gee.List<Geary.EmailIdentifier> email_ids,
+        Cancellable? cancellable = null) throws Error {
+        yield expunge_email_async(email_ids, cancellable);
+    }
+}
+
diff --git a/src/engine/imap-engine/imap-engine-generic-account.vala 
b/src/engine/imap-engine/imap-engine-generic-account.vala
index 50f6001..c7b0f4d 100644
--- a/src/engine/imap-engine/imap-engine-generic-account.vala
+++ b/src/engine/imap-engine/imap-engine-generic-account.vala
@@ -526,6 +526,31 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.AbstractAccount {
         return build_folder((ImapDB.Folder) yield local.fetch_folder_async(path, cancellable));
     }
     
+    /**
+     * Returns an Imap.Folder that is not connected (is detached) to a MinimalFolder or any other
+     * ImapEngine container.
+     *
+     * This is useful for one-shot operations that need to bypass the heavyweight synchronization
+     * routines inside MinimalFolder.  This also means that operations performed on this Folder will
+     * not be reflected in the local database unless there's a separate connection to the server
+     * that is notified or detects these changes.
+     *
+     * 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.''
+     */
+    public async Imap.Folder fetch_detached_folder_async(Geary.FolderPath path, Cancellable? cancellable)
+        throws Error {
+        check_open();
+        
+        if (local_only.has_key(path)) {
+            throw new EngineError.NOT_FOUND("%s: path %s points to local-only folder, not IMAP",
+                to_string(), path.to_string());
+        }
+        
+        return yield remote.fetch_unrecycled_folder_async(path, cancellable);
+    }
+    
     private Gee.HashMap<Geary.SpecialFolderType, Gee.ArrayList<string>> get_mailbox_search_names() {
         Gee.HashMap<Geary.SpecialFolderType, string> mailbox_search_names
             = new Gee.HashMap<Geary.SpecialFolderType, string>();
diff --git a/src/engine/imap-engine/imap-engine-minimal-folder.vala 
b/src/engine/imap-engine/imap-engine-minimal-folder.vala
index 45e49f0..eedf05b 100644
--- a/src/engine/imap-engine/imap-engine-minimal-folder.vala
+++ b/src/engine/imap-engine/imap-engine-minimal-folder.vala
@@ -1236,19 +1236,30 @@ private class Geary.ImapEngine.MinimalFolder : Geary.AbstractFolder, Geary.Folde
         replay_queue.schedule(mark);
         yield mark.wait_for_ready_async(cancellable);
     }
-
+    
     public virtual async void copy_email_async(Gee.List<Geary.EmailIdentifier> to_copy,
         Geary.FolderPath destination, Cancellable? cancellable = null) throws Error {
-        check_open("copy_email_async");
-        check_ids("copy_email_async", to_copy);
+        yield copy_email_uids_async(to_copy, destination, cancellable);
+    }
+    
+    /**
+     * Returns the destination folder's UIDs for the copied messages.
+     */
+    public async Gee.Set<Imap.UID>? copy_email_uids_async(Gee.List<Geary.EmailIdentifier> to_copy,
+        Geary.FolderPath destination, Cancellable? cancellable = null) throws Error {
+        check_open("copy_email_uids_async");
+        check_ids("copy_email_uids_async", to_copy);
         
         // watch for copying to this folder, which is treated as a no-op
         if (destination.equal_to(path))
-            return;
+            return null;
         
         CopyEmail copy = new CopyEmail(this, (Gee.List<ImapDB.EmailIdentifier>) to_copy, destination);
         replay_queue.schedule(copy);
+        
         yield copy.wait_for_ready_async(cancellable);
+        
+        return copy.destination_uids.size > 0 ? copy.destination_uids : null;
     }
 
     public virtual async void move_email_async(Gee.List<Geary.EmailIdentifier> to_move,
diff --git a/src/engine/imap-engine/replay-ops/imap-engine-copy-email.vala 
b/src/engine/imap-engine/replay-ops/imap-engine-copy-email.vala
index 0d68888..f054780 100644
--- a/src/engine/imap-engine/replay-ops/imap-engine-copy-email.vala
+++ b/src/engine/imap-engine/replay-ops/imap-engine-copy-email.vala
@@ -5,6 +5,8 @@
  */
 
 private class Geary.ImapEngine.CopyEmail : Geary.ImapEngine.SendReplayOperation {
+    public Gee.Set<Imap.UID> destination_uids = new Gee.HashSet<Imap.UID>();
+    
     private MinimalFolder engine;
     private Gee.HashSet<ImapDB.EmailIdentifier> to_copy = new Gee.HashSet<ImapDB.EmailIdentifier>();
     private Geary.FolderPath destination;
@@ -46,8 +48,12 @@ private class Geary.ImapEngine.CopyEmail : Geary.ImapEngine.SendReplayOperation
         
         if (uids != null && uids.size > 0) {
             Gee.List<Imap.MessageSet> msg_sets = Imap.MessageSet.uid_sparse(uids);
-            foreach (Imap.MessageSet msg_set in msg_sets)
-                yield engine.remote_folder.copy_email_async(msg_set, destination, cancellable);
+            foreach (Imap.MessageSet msg_set in msg_sets) {
+                Gee.Map<Imap.UID, Imap.UID>? src_dst_uids = yield engine.remote_folder.copy_email_async(
+                    msg_set, destination, cancellable);
+                if (src_dst_uids != null)
+                    destination_uids.add_all(src_dst_uids.values);
+            }
         }
         
         return ReplayOperation.Status.COMPLETED;
diff --git a/src/engine/imap-engine/replay-ops/imap-engine-create-email.vala 
b/src/engine/imap-engine/replay-ops/imap-engine-create-email.vala
index 3104c4c..3750ff0 100644
--- a/src/engine/imap-engine/replay-ops/imap-engine-create-email.vala
+++ b/src/engine/imap-engine/replay-ops/imap-engine-create-email.vala
@@ -54,7 +54,7 @@ private class Geary.ImapEngine.CreateEmail : Geary.ImapEngine.SendReplayOperatio
         // operation atomic.
         if (cancellable.is_cancelled()) {
             yield engine.remote_folder.remove_email_async(
-                new Imap.MessageSet.uid(((ImapDB.EmailIdentifier) created_id).uid), null);
+                new Imap.MessageSet.uid(((ImapDB.EmailIdentifier) created_id).uid).to_list(), null);
             
             throw new IOError.CANCELLED("CreateEmail op cancelled after create");
         }
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.vala
index 236932b..4fc13b2 100644
--- a/src/engine/imap-engine/replay-ops/imap-engine-move-email.vala
+++ b/src/engine/imap-engine/replay-ops/imap-engine-move-email.vala
@@ -71,7 +71,7 @@ private class Geary.ImapEngine.MoveEmail : Geary.ImapEngine.SendReplayOperation
             ImapDB.EmailIdentifier.to_uids(moved_ids));
         foreach (Imap.MessageSet msg_set in msg_sets) {
             yield engine.remote_folder.copy_email_async(msg_set, destination, null);
-            yield engine.remote_folder.remove_email_async(msg_set, null);
+            yield engine.remote_folder.remove_email_async(msg_set.to_list(), null);
         }
         
         return ReplayOperation.Status.COMPLETED;
diff --git a/src/engine/imap-engine/replay-ops/imap-engine-remove-email.vala 
b/src/engine/imap-engine/replay-ops/imap-engine-remove-email.vala
index 234ab36..f1ee2d3 100644
--- a/src/engine/imap-engine/replay-ops/imap-engine-remove-email.vala
+++ b/src/engine/imap-engine/replay-ops/imap-engine-remove-email.vala
@@ -62,8 +62,7 @@ private class Geary.ImapEngine.RemoveEmail : Geary.ImapEngine.SendReplayOperatio
         // that the signal has already been fired.
         Gee.List<Imap.MessageSet> msg_sets = Imap.MessageSet.uid_sparse(
             ImapDB.EmailIdentifier.to_uids(removed_ids));
-        foreach (Imap.MessageSet msg_set in msg_sets)
-            yield engine.remote_folder.remove_email_async(msg_set, cancellable);
+        yield engine.remote_folder.remove_email_async(msg_sets, cancellable);
         
         return ReplayOperation.Status.COMPLETED;
     }
diff --git a/src/engine/imap/api/imap-account.vala b/src/engine/imap/api/imap-account.vala
index 9da0272..b7de768 100644
--- a/src/engine/imap/api/imap-account.vala
+++ b/src/engine/imap/api/imap-account.vala
@@ -245,7 +245,7 @@ private class Geary.Imap.Account : BaseObject {
         
         Imap.Folder folder;
         if (!mailbox_info.attrs.is_no_select) {
-            StatusData status = yield fetch_status_async(path, StatusDataType.all(), cancellable);
+            StatusData status = yield fetch_status_async(folder_path, StatusDataType.all(), cancellable);
             
             folder = new Imap.Folder(folder_path, session_mgr, status, mailbox_info);
         } else {
@@ -257,6 +257,35 @@ private class Geary.Imap.Account : BaseObject {
         return folder;
     }
     
+    /**
+     * Returns an Imap.Folder that is not stored long-term in the Imap.Account object.
+     *
+     * This means the Imap.Folder is not re-used or used by multiple users or containers.  This is
+     * useful for one-shot operations on the server.
+     */
+    public async Imap.Folder fetch_unrecycled_folder_async(FolderPath path, Cancellable? cancellable)
+        throws Error {
+        check_open();
+        
+        MailboxInformation? mailbox_info = path_to_mailbox.get(path);
+        if (mailbox_info == null)
+            throw_not_found(path);
+        
+        // construct canonical folder path
+        FolderPath folder_path = mailbox_info.get_path(inbox_specifier);
+        
+        Imap.Folder folder;
+        if (!mailbox_info.attrs.is_no_select) {
+            StatusData status = yield fetch_status_async(folder_path, StatusDataType.all(), cancellable);
+            
+            folder = new Imap.Folder(folder_path, session_mgr, status, mailbox_info);
+        } else {
+            folder = new Imap.Folder.unselectable(folder_path, session_mgr, mailbox_info);
+        }
+        
+        return folder;
+    }
+    
     internal void folders_removed(Gee.Collection<FolderPath> paths) {
         foreach (FolderPath path in paths) {
             if (path_to_mailbox.has_key(path))
diff --git a/src/engine/imap/api/imap-folder.vala b/src/engine/imap/api/imap-folder.vala
index 82f316a..b4b6ab1 100644
--- a/src/engine/imap/api/imap-folder.vala
+++ b/src/engine/imap/api/imap-folder.vala
@@ -626,7 +626,8 @@ private class Geary.Imap.Folder : BaseObject {
         return map;
     }
     
-    public async void remove_email_async(MessageSet msg_set, Cancellable? cancellable) throws Error {
+    public async void remove_email_async(Gee.List<MessageSet> msg_sets, Cancellable? cancellable)
+        throws Error {
         check_open();
         
         Gee.List<MessageFlag> flags = new Gee.ArrayList<MessageFlag>();
@@ -634,8 +635,14 @@ private class Geary.Imap.Folder : BaseObject {
         
         Gee.List<Command> cmds = new Gee.ArrayList<Command>();
         
-        StoreCommand store_cmd = new StoreCommand(msg_set, flags, true, false);
-        cmds.add(store_cmd);
+        // Build STORE command for all MessageSets, see if all are UIDs so we can use UID EXPUNGE
+        bool all_uid = true;
+        foreach (MessageSet msg_set in msg_sets) {
+            if (!msg_set.is_uid)
+                all_uid = false;
+            
+            cmds.add(new StoreCommand(msg_set, flags, true, false));
+        }
         
         // TODO: Only use old-school EXPUNGE when closing folder (or rely on CLOSE to do that work
         // for us).  See:
@@ -644,10 +651,12 @@ private class Geary.Imap.Folder : BaseObject {
         // However, current client implementation doesn't properly close INBOX when application
         // shuts down, which means deleted messages return at application start.  See:
         // http://redmine.yorba.org/issues/6865
-        if (msg_set.is_uid && session.capabilities.supports_uidplus())
-            cmds.add(new ExpungeCommand.uid(msg_set));
-        else
+        if (all_uid && session.capabilities.supports_uidplus()) {
+            foreach (MessageSet msg_set in msg_sets)
+                cmds.add(new ExpungeCommand.uid(msg_set));
+        } else {
             cmds.add(new ExpungeCommand());
+        }
         
         yield exec_commands_async(cmds, null, null, cancellable);
     }
@@ -675,15 +684,52 @@ private class Geary.Imap.Folder : BaseObject {
         yield exec_commands_async(cmds, null, null, cancellable);
     }
     
-    public async void copy_email_async(MessageSet msg_set, Geary.FolderPath destination,
+    // Returns a mapping of the source UID to the destination UID.  If the MessageSet is not for
+    // UIDs, then null is returned.  If the server doesn't support COPYUID, null is returned.
+    public async Gee.Map<UID, UID>? copy_email_async(MessageSet msg_set, FolderPath destination,
         Cancellable? cancellable) throws Error {
         check_open();
         
         CopyCommand cmd = new CopyCommand(msg_set,
             new MailboxSpecifier.from_folder_path(destination, null));
         
-        yield exec_commands_async(Geary.iterate<Command>(cmd).to_array_list(), null,
-            null, cancellable);
+        Gee.Map<Command, StatusResponse>? responses = yield exec_commands_async(
+            Geary.iterate<Command>(cmd).to_array_list(), null, null, cancellable);
+        
+        if (!responses.has_key(cmd))
+            return null;
+        
+        StatusResponse response = responses.get(cmd);
+        if (response.response_code != null && msg_set.is_uid) {
+            Gee.List<UID>? src_uids = null;
+            Gee.List<UID>? dst_uids = null;
+            try {
+                response.response_code.get_copyuid(null, out src_uids, out dst_uids);
+            } catch (ImapError ierr) {
+                debug("Unable to retrieve COPYUID UIDs: %s", ierr.message);
+            }
+            
+            if (!Collection.is_empty(src_uids) && !Collection.is_empty(dst_uids)) {
+                Gee.Map<UID, UID> copyuids = new Gee.HashMap<UID, UID>();
+                int ctr = 0;
+                for (;;) {
+                    UID? src_uid = (ctr < src_uids.size) ? src_uids[ctr] : null;
+                    UID? dst_uid = (ctr < dst_uids.size) ? dst_uids[ctr] : null;
+                    
+                    if (src_uid != null && dst_uid != null)
+                        copyuids.set(src_uid, dst_uid);
+                    else
+                        break;
+                    
+                    ctr++;
+                }
+                
+                if (copyuids.size > 0)
+                    return copyuids;
+            }
+        }
+        
+        return null;
     }
     
     public async Gee.SortedSet<Imap.UID>? search_async(SearchCriteria criteria, Cancellable? cancellable)
diff --git a/src/engine/imap/command/imap-message-set.vala b/src/engine/imap/command/imap-message-set.vala
index 85af92a..e8399d4 100644
--- a/src/engine/imap/command/imap-message-set.vala
+++ b/src/engine/imap/command/imap-message-set.vala
@@ -20,6 +20,8 @@ public class Geary.Imap.MessageSet : BaseObject {
     // etc.)
     private const int MAX_SPARSE_VALUES_PER_SET = 50;
     
+    private delegate void ParserCallback(int64 value) throws ImapError;
+    
     /**
      * True if the { link MessageSet} was created with a UID or a UID range.
      *
@@ -109,6 +111,121 @@ public class Geary.Imap.MessageSet : BaseObject {
     }
     
     /**
+     * Parses a string representing a { link MessageSet} into a List of { link SequenceNumber}s.
+     *
+     * See the note at { link parse_uid} about limitations of this method.
+     *
+     * Returns null if the string or parsed set is empty.
+     *
+     * @see uid_parse
+     */
+    public static Gee.List<SequenceNumber>? parse(string str) throws ImapError {
+        Gee.List<SequenceNumber> seq_nums = new Gee.ArrayList<SequenceNumber>();
+        parse_string(str, (value) => { seq_nums.add(new SequenceNumber.checked(value)); });
+        
+        return seq_nums.size > 0 ? seq_nums : null;
+    }
+    
+    /**
+     * Parses a string representing a { link MessageSet} into a List of { link UID}s.
+     *
+     * Note that this is currently designed for parsing message set responses from the server,
+     * specifically for COPYUID, which has some limitations in what may be returned.  Notably, the
+     * asterisk ("*") symbol may not be returned.  Thus, this method does not properly parse
+     * the full range of message set notation and can't even be trusted to reverse-parse the output
+     * of this class.  A full implementation might be considered later.
+     *
+     * Because COPYUID returns values in the order copied, this method returns a List, not a Set,
+     * of values.  They are in the order received (and properly deal with ranges in backwards
+     * order, i.e. "12:10").  This means duplicates may be encountered multiple times if the server
+     * returns those values.
+     *
+     * Returns null if the string or parsed set is empty.
+     */
+    public static Gee.List<UID>? uid_parse(string str) throws ImapError {
+        Gee.List<UID> uids = new Gee.ArrayList<UID>();
+        parse_string(str, (value) => { uids.add(new UID.checked(value)); });
+        
+        return uids.size > 0 ? uids : null;
+    }
+    
+    private static void parse_string(string str, ParserCallback cb) throws ImapError {
+        StringBuilder acc = new StringBuilder();
+        int64 start_range = -1;
+        bool in_range = false;
+        
+        unichar ch;
+        int index = 0;
+        while (str.get_next_char(ref index, out ch)) {
+            // if number, add to accumulator
+            if (ch.isdigit()) {
+                acc.append_unichar(ch);
+                
+                continue;
+            }
+            
+            // look for special characters and deal with them
+            switch (ch) {
+                case ':':
+                    // range separator
+                    if (in_range)
+                        throw new ImapError.INVALID("Bad range specifier in message set \"%s\"", str);
+                    
+                    in_range = true;
+                    
+                    // store current accumulated value as start of range
+                    start_range = int64.parse(acc.str);
+                    acc = new StringBuilder();
+                break;
+                
+                case ',':
+                    // number separator
+                    
+                    // if in range, treat as end-of-range
+                    if (in_range) {
+                        // don't be forgiving here
+                        if (String.is_empty(acc.str))
+                            throw new ImapError.INVALID("Bad range specifier in message set \"%s\"", str);
+                        
+                        process_range(start_range, int64.parse(acc.str), cb);
+                        in_range = false;
+                    } else {
+                        // Be forgiving here
+                        if (String.is_empty(acc.str))
+                            continue;
+                        
+                        cb(int64.parse(acc.str));
+                    }
+                    
+                    // reset accumulator
+                    acc = new StringBuilder();
+                break;
+                
+                default:
+                    // unknown character, treat with great violence
+                    throw new ImapError.INVALID("Bad character '%s' in message set \"%s\"",
+                        ch.to_string(), str);
+            }
+        }
+        
+        // report last bit remaining in accumulator
+        if (!String.is_empty(acc.str)) {
+            if (in_range)
+                process_range(start_range, int64.parse(acc.str), cb);
+            else
+                cb(int64.parse(acc.str));
+        } else if (in_range) {
+            throw new ImapError.INVALID("Incomplete range specifier in message set \"%s\"", str);
+        }
+    }
+    
+    private static void process_range(int64 start, int64 end, ParserCallback cb) throws ImapError {
+        int64 count_by = (start <= end) ? 1 : -1;
+        for (int64 ctr = start; ctr != end + count_by; ctr += count_by)
+            cb(ctr);
+    }
+    
+    /**
      * Convert a collection of { link SequenceNumber}s into a list of { link MessageSet}s.
      *
      * Although this could return a single MessageSet, large collections could create an IMAP
@@ -247,6 +364,13 @@ public class Geary.Imap.MessageSet : BaseObject {
         return new UnquotedStringParameter(value);
     }
     
+    /**
+     * Returns the { link MessageSet} in a Gee.List.
+     */
+    public Gee.List<MessageSet> to_list() {
+        return iterate<MessageSet>(this).to_array_list();
+    }
+    
     public string to_string() {
         return "%s::%s".printf(is_uid ? "UID" : "pos", value);
     }
diff --git a/src/engine/imap/parameter/imap-list-parameter.vala 
b/src/engine/imap/parameter/imap-list-parameter.vala
index e561102..4e57d95 100644
--- a/src/engine/imap/parameter/imap-list-parameter.vala
+++ b/src/engine/imap/parameter/imap-list-parameter.vala
@@ -325,6 +325,44 @@ public class Geary.Imap.ListParameter : Geary.Imap.Parameter {
     }
     
     //
+    // Number retrieval
+    //
+    
+    /**
+     * Returns a { link NumberParameter} at index, null if not of that type.
+     *
+     * @see get_if
+     */
+    public NumberParameter? get_if_number(int index) {
+        return (NumberParameter?) get_if(index, typeof(NumberParameter));
+    }
+    
+    /**
+     * Returns a { link NumberParameter} at index.
+     *
+     * Like { link get_as_string}, this method will attempt some coercion.  In this case,
+     * { link QuotedStringParameter} and { link UnquotedStringParameter}s will be converted to
+     * NumberParameter, if appropriate.
+     */
+    public NumberParameter get_as_number(int index) throws ImapError {
+        Parameter param = get_required(index);
+        
+        NumberParameter? numberp = param as NumberParameter;
+        if (numberp != null)
+            return numberp;
+        
+        StringParameter? stringp = param as StringParameter;
+        if (stringp != null) {
+            numberp = stringp.coerce_to_number_parameter();
+            if (numberp != null)
+                return numberp;
+        }
+        
+        throw new ImapError.TYPE_ERROR("Parameter %d not of type number or string (is %s)", index,
+            param.get_type().name());
+    }
+    
+    //
     // List retrieval
     //
     
diff --git a/src/engine/imap/parameter/imap-number-parameter.vala 
b/src/engine/imap/parameter/imap-number-parameter.vala
index e32c3cc..260db0d 100644
--- a/src/engine/imap/parameter/imap-number-parameter.vala
+++ b/src/engine/imap/parameter/imap-number-parameter.vala
@@ -5,7 +5,8 @@
  */
 
 /**
- * A representation of a numerical { link Parameter} in an IMAP { link Command}.
+ * A representation of a numerical { link Parameter} in an IMAP { link Command} or
+ * { link ServerResponse}.
  *
  * See [[http://tools.ietf.org/html/rfc3501#section-4.2]]
  */
@@ -86,6 +87,11 @@ public class Geary.Imap.NumberParameter : UnquotedStringParameter {
                 has_nonzero = true;
         }
         
+        // watch for negative but no numeric portion
+        if (is_negative && str.length == 1)
+            return false;
+        
+        // no such thing as negative zero
         if (is_negative && !has_nonzero)
             is_negative = false;
         
diff --git a/src/engine/imap/parameter/imap-string-parameter.vala 
b/src/engine/imap/parameter/imap-string-parameter.vala
index 01b6f0b..a1978cf 100644
--- a/src/engine/imap/parameter/imap-string-parameter.vala
+++ b/src/engine/imap/parameter/imap-string-parameter.vala
@@ -188,5 +188,23 @@ public abstract class Geary.Imap.StringParameter : Geary.Imap.Parameter {
         
         return int64.parse(ascii).clamp(clamp_min, clamp_max);
     }
+    
+    /**
+     * Attempts to coerce a { link StringParameter} into a { link NumberParameter}.
+     *
+     * Returns null if unsuitable for a NumberParameter.
+     *
+     * @see NumberParameter.is_ascii_number
+     */
+    public NumberParameter? coerce_to_number_parameter() {
+        NumberParameter? numberp = this as NumberParameter;
+        if (numberp != null)
+            return numberp;
+        
+        if (NumberParameter.is_ascii_numeric(ascii, null))
+            return new NumberParameter.from_ascii(ascii);
+        
+        return null;
+    }
 }
 
diff --git a/src/engine/imap/response/imap-response-code-type.vala 
b/src/engine/imap/response/imap-response-code-type.vala
index 3bb0c84..c5c52be 100644
--- a/src/engine/imap/response/imap-response-code-type.vala
+++ b/src/engine/imap/response/imap-response-code-type.vala
@@ -23,6 +23,7 @@ public class Geary.Imap.ResponseCodeType : BaseObject, Gee.Hashable<ResponseCode
     public const string BADCHARSET = "badcharset";
     public const string CAPABILITY = "capability";
     public const string CLIENTBUG = "clientbug";
+    public const string COPYUID = "copyuid";
     public const string MYRIGHTS = "myrights";
     public const string NEWNAME = "newname";
     public const string NONEXISTANT = "nonexistant";
diff --git a/src/engine/imap/response/imap-response-code.vala 
b/src/engine/imap/response/imap-response-code.vala
index fbd8ce8..e8887e2 100644
--- a/src/engine/imap/response/imap-response-code.vala
+++ b/src/engine/imap/response/imap-response-code.vala
@@ -89,6 +89,26 @@ public class Geary.Imap.ResponseCode : Geary.Imap.ListParameter {
         return capabilities;
     }
     
+    /**
+     * Parses the { link ResponseCode} into UIDPLUS' COPYUID response, if possible.
+     *
+     * Note that the { link UID}s are returned from the server in the order the messages
+     * were copied.
+     *
+     * See [[http://tools.ietf.org/html/rfc4315#section-3]]
+     *
+     * @throws ImapError.INVALID if not COPYUID.
+     */
+    public void get_copyuid(out UIDValidity uidvalidity, out Gee.List<UID>? source_uids,
+        out Gee.List<UID>? destination_uids) throws ImapError {
+        if (!get_response_code_type().is_value(ResponseCodeType.COPYUID))
+            throw new ImapError.INVALID("Not COPYUID response code: %s", to_string());
+        
+        uidvalidity = new UIDValidity.checked(get_as_number(1).as_int64());
+        source_uids = MessageSet.uid_parse(get_as_string(2).ascii);
+        destination_uids = MessageSet.uid_parse(get_as_string(3).ascii);
+    }
+    
     public override string to_string() {
         return "[%s]".printf(stringize_list());
     }
diff --git a/src/engine/imap/transport/imap-deserializer.vala 
b/src/engine/imap/transport/imap-deserializer.vala
index 63cfd2f..3f6be64 100644
--- a/src/engine/imap/transport/imap-deserializer.vala
+++ b/src/engine/imap/transport/imap-deserializer.vala
@@ -449,6 +449,8 @@ public class Geary.Imap.Deserializer : BaseObject {
         
         if (quoted)
             save_parameter(new QuotedStringParameter(str));
+        else if (NumberParameter.is_ascii_numeric(str, null))
+            save_parameter(new NumberParameter.from_ascii(str));
         else
             save_parameter(new UnquotedStringParameter(str));
         
diff --git a/src/engine/util/util-collection.vala b/src/engine/util/util-collection.vala
index 294de11..dc9f43d 100644
--- a/src/engine/util/util-collection.vala
+++ b/src/engine/util/util-collection.vala
@@ -8,6 +8,10 @@ namespace Geary.Collection {
 
 public delegate uint8 ByteTransformer(uint8 b);
 
+public inline bool is_empty(Gee.Collection? c) {
+    return c == null || c.size == 0;
+}
+
 // A substitute for ArrayList<G>.wrap() for compatibility with older versions of Gee.
 public Gee.ArrayList<G> array_list_wrap<G>(G[] a, owned Gee.EqualDataFunc<G>? equal_func = null) {
     Gee.ArrayList<G> list = new Gee.ArrayList<G>(equal_func);



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