Bug 1520338 - Messages API function to move, copy, or delete messages; r=Fallen
authorGeoff Lankow <geoff@darktrojan.net>
Fri, 25 Jan 2019 17:32:10 +1300
changeset 34295 132113b4ce51c899bc8932f30401be73728f1268
parent 34294 5565f32e3a8703e4d7b5bfc558821cb7e2a14f82
child 34296 bd2ed7ad33c9714f381b6d8c5d9d8c5f4a1d40d8
push id389
push userclokep@gmail.com
push dateMon, 18 Mar 2019 19:01:53 +0000
reviewersFallen
bugs1520338
Bug 1520338 - Messages API function to move, copy, or delete messages; r=Fallen
mail/components/extensions/parent/ext-messages.js
mail/components/extensions/schemas/messages.json
mail/components/extensions/test/xpcshell/test_ext_messages.js
mail/locales/en-US/chrome/messenger/addons.properties
--- a/mail/components/extensions/parent/ext-messages.js
+++ b/mail/components/extensions/parent/ext-messages.js
@@ -1,14 +1,17 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 ChromeUtils.defineModuleGetter(this, "MailServices", "resource:///modules/MailServices.jsm");
 ChromeUtils.defineModuleGetter(this, "MsgHdrToMimeMessage", "resource:///modules/gloda/mimemsg.js");
+ChromeUtils.defineModuleGetter(this, "toXPCOMArray", "resource:///modules/iteratorUtils.jsm");
+
+var { DefaultMap } = ExtensionUtils;
 
 /**
  * Takes a part of a MIME message (as retrieved with MsgHdrToMimeMessage) and filters
  * out the properties we don't want to send to extensions.
  */
 function convertMessagePart(part) {
   let partObject = {};
   for (let key of ["body", "contentType", "headers", "name", "partName", "size"]) {
@@ -19,16 +22,75 @@ function convertMessagePart(part) {
   if (Array.isArray(part.parts) && part.parts.length > 0) {
     partObject.parts = part.parts.map(convertMessagePart);
   }
   return partObject;
 }
 
 this.messages = class extends ExtensionAPI {
   getAPI(context) {
+    function collectMessagesInFolders(messageIds) {
+      let folderMap = new DefaultMap(() => new Set());
+
+      for (let id of messageIds) {
+        let msgHdr = messageTracker.getMessage(id);
+        if (!msgHdr) {
+          continue;
+        }
+
+        let sourceSet = folderMap.get(msgHdr.folder);
+        sourceSet.add(msgHdr);
+      }
+
+      return folderMap;
+    }
+
+    async function moveOrCopyMessages(messageIds, { accountId, path }, isMove) {
+      let destinationURI = folderPathToURI(accountId, path);
+      let destinationFolder = MailServices.folderLookup.getFolderForURL(destinationURI);
+      let folderMap = collectMessagesInFolders(messageIds);
+      let promises = [];
+      for (let [sourceFolder, sourceSet] of folderMap.entries()) {
+        if (sourceFolder == destinationFolder) {
+          continue;
+        }
+
+        let messages = toXPCOMArray(sourceSet.values(), Ci.nsIMutableArray);
+        promises.push(new Promise((resolve, reject) => {
+          MailServices.copy.CopyMessages(
+            sourceFolder, messages, destinationFolder, isMove, {
+              OnStartCopy() {
+              },
+              OnProgress(progress, progressMax) {
+              },
+              SetMessageKey(key) {
+              },
+              GetMessageId(messageId) {
+              },
+              OnStopCopy(status) {
+                if (status == Cr.NS_OK) {
+                  resolve();
+                } else {
+                  reject(status);
+                }
+              },
+            }, /* msgWindow */ null, /* allowUndo */ true);
+        }));
+      }
+      try {
+        await Promise.all(promises);
+      } catch (ex) {
+        Cu.reportError(ex);
+        if (isMove) {
+          throw new ExtensionError(`Unexpected error moving messages: ${ex}`);
+        }
+        throw new ExtensionError(`Unexpected error copying messages: ${ex}`);
+      }
+    }
+
     return {
       messages: {
         async list({ accountId, path }) {
           let uri = folderPathToURI(accountId, path);
           let folder = MailServices.folderLookup.getFolderForURL(uri);
 
           return messageListTracker.startList(folder.messages, context);
         },
@@ -60,16 +122,62 @@ this.messages = class extends ExtensionA
           if (Array.isArray(newProperties.tags)) {
             newProperties.tags = newProperties.tags.filter(MailServices.tags.isValidKey);
             msgHdr.setProperty("keywords", newProperties.tags.join(" "));
             for (let window of Services.wm.getEnumerator("mail:3pane")) {
               window.OnTagsChange();
             }
           }
         },
+        async move(messageIds, destination) {
+          return moveOrCopyMessages(messageIds, destination, true);
+        },
+        async copy(messageIds, destination) {
+          return moveOrCopyMessages(messageIds, destination, false);
+        },
+        async delete(messageIds, skipTrash) {
+          let folderMap = collectMessagesInFolders(messageIds);
+          for (let sourceFolder of folderMap.keys()) {
+            if (!sourceFolder.canDeleteMessages) {
+              throw new ExtensionError(`Unable to delete messages in "${sourceFolder.prettyName}"`);
+            }
+          }
+          let promises = [];
+          for (let [sourceFolder, sourceSet] of folderMap.entries()) {
+            promises.push(new Promise((resolve, reject) => {
+              let messages = toXPCOMArray(sourceSet.values(), Ci.nsIMutableArray);
+              sourceFolder.deleteMessages(
+                messages, /* msgWindow */ null, /* deleteStorage */ skipTrash,
+                /* isMove */ false, {
+                  OnStartCopy() {
+                  },
+                  OnProgress(progress, progressMax) {
+                  },
+                  SetMessageKey(key) {
+                  },
+                  GetMessageId(messageId) {
+                  },
+                  OnStopCopy(status) {
+                    if (status == Cr.NS_OK) {
+                      resolve();
+                    } else {
+                      reject(status);
+                    }
+                  },
+                }, /* allowUndo */ true
+              );
+            }));
+          }
+          try {
+            await Promise.all(promises);
+          } catch (ex) {
+            Cu.reportError(ex);
+            throw new ExtensionError(`Unexpected error deleting messages: ${ex}`);
+          }
+        },
         async listTags() {
           return MailServices.tags.getAllTags({}).map(({ key, tag, color, ordinal }) => {
             return {
               key,
               tag,
               color,
               ordinal,
             };
--- a/mail/components/extensions/schemas/messages.json
+++ b/mail/components/extensions/schemas/messages.json
@@ -3,16 +3,17 @@
     "namespace": "manifest",
     "types": [
       {
         "$extend": "OptionalPermission",
         "choices": [
           {
             "type": "string",
             "enum": [
+              "messagesMove",
               "messagesRead"
             ]
           }
         ]
       }
     ]
   },
   {
@@ -72,17 +73,18 @@
       {
         "name": "update",
         "type": "function",
         "description": "Marks or unmarks a message as read, starred, or tagged.",
         "async": true,
         "parameters": [
           {
             "name": "messageId",
-            "type": "integer"
+            "type": "integer",
+            "minimum": 1
           },
           {
             "name": "newProperties",
             "type": "object",
             "properties": {
               "read": {
                 "type": "boolean",
                 "description": "Marks the message as read or unread.",
@@ -101,16 +103,92 @@
                   "type": "string"
                 }
               }
             }
           }
         ]
       },
       {
+        "name": "move",
+        "type": "function",
+        "description": "Moves messages to a specified folder.",
+        "async": true,
+        "permissions": [
+          "messagesMove"
+        ],
+        "parameters": [
+          {
+            "name": "messageIds",
+            "type": "array",
+            "description": "The IDs of the messages to move.",
+            "items": {
+              "type": "integer",
+              "minimum": 1
+            }
+          },
+          {
+            "name": "destination",
+            "$ref": "accounts.MailFolder",
+            "description": "The folder to move the messages to."
+          }
+        ]
+      },
+      {
+        "name": "copy",
+        "type": "function",
+        "description": "Copies messages to a specified folder.",
+        "async": true,
+        "permissions": [
+          "messagesMove"
+        ],
+        "parameters": [
+          {
+            "name": "messageIds",
+            "type": "array",
+            "description": "The IDs of the messages to copy.",
+            "items": {
+              "type": "integer",
+              "minimum": 1
+            }
+          },
+          {
+            "name": "destination",
+            "$ref": "accounts.MailFolder",
+            "description": "The folder to copy the messages to."
+          }
+        ]
+      },
+      {
+        "name": "delete",
+        "type": "function",
+        "description": "Deletes messages, or moves them to the trash folder.",
+        "async": true,
+        "permissions": [
+          "messagesMove"
+        ],
+        "parameters": [
+          {
+            "name": "messageIds",
+            "type": "array",
+            "description": "The IDs of the messages to delete.",
+            "items": {
+              "type": "integer",
+              "minimum": 1
+            }
+          },
+          {
+            "name": "skipTrash",
+            "type": "boolean",
+            "description": "If true, the message will be permanently deleted without warning the user. If false or not specified, it will be moved to the trash folder.",
+            "optional": true
+          }
+        ]
+      },
+      {
         "name": "listTags",
         "type": "function",
         "description": "Returns a list of tags that can be set on messages, and their human-friendly name, colour, and sort order.",
         "async": true,
         "parameters": []
       }
     ]
   }
--- a/mail/components/extensions/test/xpcshell/test_ext_messages.js
+++ b/mail/components/extensions/test/xpcshell/test_ext_messages.js
@@ -6,19 +6,23 @@
 
 ChromeUtils.import("resource://testing-common/ExtensionXPCShellUtils.jsm");
 ExtensionTestUtils.init(this);
 
 let account, rootFolder, subFolders;
 async function run_test() {
   account = createAccount();
   rootFolder = account.incomingServer.rootFolder;
+  rootFolder.createSubfolder("test1", null);
+  rootFolder.createSubfolder("test2", null);
+  rootFolder.createSubfolder("test3", null);
   subFolders = [...rootFolder.subFolders];
-  createMessages(subFolders[0], 99);
-  createMessages(subFolders[1], 1);
+  createMessages(subFolders[0], 99); // Trash
+  createMessages(subFolders[1], 1); // Unsent messages
+  createMessages(subFolders[2], 5); // test1
 
   run_next_test();
 }
 
 add_task(async function test_pagination() {
   let extension = ExtensionTestUtils.loadExtension({
     background: async () => {
       function awaitMessage(messageToSend) {
@@ -172,8 +176,152 @@ add_task(async function test_update() {
   ok(!message.isFlagged);
   ok(!message.isRead);
   equal(message.getProperty("keywords"), "");
   extension.sendMessage();
 
   await extension.awaitFinish("finished");
   await extension.unload();
 });
+
+add_task(async function test_move_copy_delete() {
+  let extension = ExtensionTestUtils.loadExtension({
+    async background() {
+      function awaitMessage() {
+        return new Promise(resolve => {
+          browser.test.onMessage.addListener(function listener(...args) {
+            browser.test.onMessage.removeListener(listener);
+            resolve(args);
+          });
+        });
+      }
+
+      async function checkMessagesInFolder(expectedIndices, folder) {
+        let expectedSubjects = expectedIndices.map(i => subjects[i]);
+        let {messages: actualMessages} = await browser.messages.list(folder);
+
+        browser.test.assertEq(expectedSubjects.length, actualMessages.length);
+        for (let m of actualMessages) {
+          browser.test.assertTrue(expectedSubjects.includes(m.subject));
+          let index = subjects.indexOf(m.subject);
+          ids[index] = m.id;
+        }
+
+        // Return the messages for convenience.
+        return actualMessages;
+      }
+
+      let [accountId] = await awaitMessage();
+      let {folders} = await browser.accounts.get(accountId);
+      let testFolder1 = folders.find(f => f.name == "test1");
+      let testFolder2 = folders.find(f => f.name == "test2");
+      let testFolder3 = folders.find(f => f.name == "test3");
+      let trashFolder = folders.find(f => f.name == "Trash");
+
+      let {messages: folder1Messages} = await browser.messages.list(testFolder1);
+      // Since the ID of a message changes when it is moved, track by subject.
+      let ids = folder1Messages.map(m => m.id);
+      let subjects = folder1Messages.map(m => m.subject);
+
+      // To help with debugging, output the IDs of our five messages.
+      // Conveniently at this point we know the messages should be numbered 101-105,
+      // (since we used 100 messages in the previous two tests) so I've put the
+      // expected values in comments.
+      browser.test.log(ids.join(", ")); // 101, 102, 103, 104, 105
+
+      // Move one message to another folder.
+      await browser.messages.move([ids[0]], testFolder2);
+      await checkMessagesInFolder([1, 2, 3, 4], testFolder1);
+      await checkMessagesInFolder([0], testFolder2);
+      browser.test.log(ids.join(", ")); // 106, 102, 103, 104, 105
+
+      // And back again.
+      await browser.messages.move([ids[0]], testFolder1);
+      await checkMessagesInFolder([0, 1, 2, 3, 4], testFolder1);
+      await checkMessagesInFolder([], testFolder2);
+      browser.test.log(ids.join(", ")); // 101, 102, 103, 103, 105
+
+      // Move two messages to another folder.
+      await browser.messages.move([ids[1], ids[3]], testFolder2);
+      await checkMessagesInFolder([0, 2, 4], testFolder1);
+      await checkMessagesInFolder([1, 3], testFolder2);
+      browser.test.log(ids.join(", ")); // 101, 107, 103, 108, 105
+
+      // Move one back again.
+      await browser.messages.move([ids[3]], testFolder1);
+      await checkMessagesInFolder([0, 2, 3, 4], testFolder1);
+      await checkMessagesInFolder([1], testFolder2);
+      browser.test.log(ids.join(", ")); // 101, 107, 103, 104, 105
+
+      // Move messages from different folders to a third folder.
+      await browser.messages.move([ids[1], ids[3]], testFolder3);
+      await checkMessagesInFolder([0, 2, 4], testFolder1);
+      await checkMessagesInFolder([], testFolder2);
+      await checkMessagesInFolder([1, 3], testFolder3);
+      browser.test.log(ids.join(", ")); // 101, 109, 103, 110, 105
+
+      // Move a message to the folder it's already in.
+      await browser.messages.move([ids[1]], testFolder3);
+      await checkMessagesInFolder([1, 3], testFolder3);
+      browser.test.log(ids.join(", ")); // 101, 109, 103, 110, 105
+
+      // Move no messages.
+      await browser.messages.move([], testFolder3);
+      await checkMessagesInFolder([0, 2, 4], testFolder1);
+      await checkMessagesInFolder([], testFolder2);
+      await checkMessagesInFolder([1, 3], testFolder3);
+      browser.test.log(ids.join(", ")); // 101, 109, 103, 110, 105
+
+      // Move a non-existent message.
+      await browser.messages.move([9999], testFolder1);
+      await checkMessagesInFolder([0, 2, 4], testFolder1);
+      browser.test.log(ids.join(", ")); // 101, 109, 103, 110, 105
+
+      // Move to a non-existent folder.
+      browser.test.assertRejects(browser.messages.move([ids[0]], {accountId, path: "/missing"}));
+
+      // Put everything back where it was at the start of the test.
+      await browser.messages.move(ids, testFolder1);
+
+      // Copy one message to another folder.
+      await browser.messages.copy([ids[4]], testFolder2);
+      await checkMessagesInFolder([0, 1, 2, 3, 4], testFolder1);
+      let {messages: folder2Messages} = await browser.messages.list(testFolder2);
+      browser.test.assertEq(1, folder2Messages.length);
+      browser.test.assertEq(subjects[4], folder2Messages[0].subject);
+      browser.test.assertTrue(folder2Messages[0].id != ids[4]);
+      ids.push(folder2Messages[0].id);
+      browser.test.log(ids.join(", ")); // 101, 102, 103, 104, 105, 111
+
+      // Delete the copied message.
+      await browser.messages.delete([ids.pop()], true);
+      await checkMessagesInFolder([0, 1, 2, 3, 4], testFolder1);
+      await checkMessagesInFolder([], testFolder2);
+      await checkMessagesInFolder([], testFolder3);
+      browser.test.log(ids.join(", ")); // 101, 102, 103, 104, 105
+
+      // Move a message to the trash.
+      let trashedMessage = await browser.messages.get(ids.pop());
+      await browser.messages.delete([trashedMessage.id], false);
+      await checkMessagesInFolder([0, 1, 2, 3], testFolder1);
+      await checkMessagesInFolder([], testFolder2);
+      await checkMessagesInFolder([], testFolder3);
+
+      let {messages: trashFolderMessages} = await browser.messages.list(trashFolder);
+      browser.test.assertTrue(trashFolderMessages.find(m => m.subject == trashedMessage.subject));
+      browser.test.log(ids.join(", ")); // 101, 102, 103, 104
+
+      browser.test.notifyPass("finished");
+    },
+    manifest: {
+      permissions: ["accountsRead", "messagesMove", "messagesRead"],
+    },
+  });
+
+  Services.prefs.setIntPref("extensions.webextensions.messagesPerPage", 1000);
+
+  await extension.startup();
+  extension.sendMessage(account.key);
+  await extension.awaitFinish("finished");
+  await extension.unload();
+
+  Services.prefs.clearUserPref("extensions.webextensions.messagesPerPage");
+});
--- a/mail/locales/en-US/chrome/messenger/addons.properties
+++ b/mail/locales/en-US/chrome/messenger/addons.properties
@@ -158,16 +158,17 @@ webextPerms.description.clipboardWrite=I
 webextPerms.description.devtools=Extend developer tools to access your data in open tabs
 webextPerms.description.dns=Access IP address and hostname information
 webextPerms.description.downloads=Download files and read and modify the browser’s download history
 webextPerms.description.downloads.open=Open files downloaded to your computer
 webextPerms.description.find=Read the text of all open tabs
 webextPerms.description.geolocation=Access your location
 webextPerms.description.history=Access browsing history
 webextPerms.description.management=Monitor extension usage and manage themes
+webextPerms.description.messagesMove=Move, copy, or delete your email messages
 webextPerms.description.messagesRead=Read your email messages and mark or tag them
 # LOCALIZATION NOTE (webextPerms.description.nativeMessaging)
 # %S will be replaced with the name of the application
 webextPerms.description.nativeMessaging=Exchange messages with programs other than %S
 webextPerms.description.notifications=Display notifications to you
 webextPerms.description.pkcs11=Provide cryptographic authentication services
 webextPerms.description.privacy=Read and modify privacy settings
 webextPerms.description.proxy=Control browser proxy settings