diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 251b3b07..64fb8d6b 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 d409efc5..7e4ba310 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 00000000..ce46e755 --- /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 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 00000000..57ea1dfd --- /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 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 e8839c08..16e55293 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 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 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? 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 00000000..8583eafd --- /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 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 50f60016..c7b0f4d1 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> get_mailbox_search_names() { Gee.HashMap mailbox_search_names = new Gee.HashMap(); diff --git a/src/engine/imap-engine/imap-engine-minimal-folder.vala b/src/engine/imap-engine/imap-engine-minimal-folder.vala index 45e49f0c..eedf05be 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 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? copy_email_uids_async(Gee.List 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) 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 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 0d688887..f054780f 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 destination_uids = new Gee.HashSet(); + private MinimalFolder engine; private Gee.HashSet to_copy = new Gee.HashSet(); private Geary.FolderPath destination; @@ -46,8 +48,12 @@ private class Geary.ImapEngine.CopyEmail : Geary.ImapEngine.SendReplayOperation if (uids != null && uids.size > 0) { Gee.List 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? 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 3104c4c6..3750ff0e 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 236932b8..4fc13b20 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 234ab368..f1ee2d3f 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 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 9da0272b..b7de7681 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 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 82f316af..b4b6ab1f 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 msg_sets, Cancellable? cancellable) + throws Error { check_open(); Gee.List flags = new Gee.ArrayList(); @@ -634,8 +635,14 @@ private class Geary.Imap.Folder : BaseObject { Gee.List cmds = new Gee.ArrayList(); - 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? 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(cmd).to_array_list(), null, - null, cancellable); + Gee.Map? responses = yield exec_commands_async( + Geary.iterate(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? src_uids = null; + Gee.List? 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 copyuids = new Gee.HashMap(); + 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? 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 85af92a4..e8399d4e 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. * @@ -108,6 +110,121 @@ public class Geary.Imap.MessageSet : BaseObject { is_uid = true; } + /** + * 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? parse(string str) throws ImapError { + Gee.List seq_nums = new Gee.ArrayList(); + 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_parse(string str) throws ImapError { + Gee.List uids = new Gee.ArrayList(); + 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. * @@ -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 to_list() { + return iterate(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 e561102a..4e57d951 100644 --- a/src/engine/imap/parameter/imap-list-parameter.vala +++ b/src/engine/imap/parameter/imap-list-parameter.vala @@ -324,6 +324,44 @@ public class Geary.Imap.ListParameter : Geary.Imap.Parameter { return stringp ?? StringParameter.get_best_for(""); } + // + // 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 e32c3ccc..260db0d3 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 01b6f0b9..a1978cf1 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 3bb0c84e..c5c52beb 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? source_uids, + out Gee.List? 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 63cfd2fa..3f6be64d 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 294de11b..dc9f43d6 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.wrap() for compatibility with older versions of Gee. public Gee.ArrayList array_list_wrap(G[] a, owned Gee.EqualDataFunc? equal_func = null) { Gee.ArrayList list = new Gee.ArrayList(equal_func);