Bug 1613535 - Handle message attachments in browser.compose API functions. r=mkmelin
authorGeoff Lankow <geoff@darktrojan.net>
Mon, 04 May 2020 16:03:05 +1200
changeset 39070 10edd0b971021b4d16a78e4fdbb299357e2a36b4
parent 39069 8a54f8665db25c3ce7b77945c16d0b206f05f39c
child 39071 f0799dbf510175a46545e62bff136187f04cd9ab
push id402
push userclokep@gmail.com
push dateMon, 29 Jun 2020 20:48:04 +0000
reviewersmkmelin
bugs1613535
Bug 1613535 - Handle message attachments in browser.compose API functions. r=mkmelin
mail/base/content/mailWidgets.js
mail/components/compose/content/MsgComposeCommands.js
mail/components/extensions/child/ext-compose.js
mail/components/extensions/child/ext-mail.js
mail/components/extensions/jar.mn
mail/components/extensions/parent/.eslintrc.js
mail/components/extensions/parent/ext-compose.js
mail/components/extensions/schemas/compose.json
mail/components/extensions/test/browser/browser.ini
mail/components/extensions/test/browser/browser_ext_compose_attachments.js
--- a/mail/base/content/mailWidgets.js
+++ b/mail/base/content/mailWidgets.js
@@ -1524,17 +1524,16 @@
     appendItem(attachment, name) {
       // -1 appends due to the way getItemAtIndex is implemented.
       return this.insertItemAt(-1, attachment, name);
     }
 
     insertItemAt(index, attachment, name) {
       let item = this.ownerDocument.createXULElement("richlistitem");
       item.classList.add("attachmentItem");
-      item.setAttribute("name", name || attachment.name);
       item.setAttribute("role", "option");
 
       let itemContainer = this.ownerDocument.createXULElement("hbox");
       itemContainer.setAttribute("flex", "1");
       itemContainer.classList.add("attachmentcell-content");
 
       item.addEventListener("dblclick", event => {
         let evt = document.createEvent("XULCommandEvent");
@@ -1564,42 +1563,58 @@
       textContainer.classList.add("attachmentcell-text");
       let textName = this.ownerDocument.createXULElement("hbox");
       textName.setAttribute("flex", "1");
       textName.classList.add("attachmentcell-nameselection");
       let textLabel = this.ownerDocument.createXULElement("label");
       textLabel.setAttribute("flex", "1");
       textLabel.setAttribute("crop", "center");
       textLabel.classList.add("attachmentcell-name");
-      textLabel.setAttribute("value", name || attachment.name);
       textName.appendChild(textLabel);
 
       let spacer = this.ownerDocument.createXULElement("spacer");
       spacer.setAttribute("flex", "99999");
 
       let sizeLabel = this.ownerDocument.createXULElement("label");
       sizeLabel.classList.add("attachmentcell-size");
 
       textContainer.appendChild(textName);
       textContainer.appendChild(spacer);
       textContainer.appendChild(sizeLabel);
 
       itemContainer.appendChild(iconContainer);
       itemContainer.appendChild(textContainer);
       item.appendChild(itemContainer);
 
+      let imageSize = this.sizes[this.getAttribute("view")] || 16;
+      item.setAttribute("imagesize", imageSize);
+      item.setAttribute("context", this.getAttribute("itemcontext"));
+
+      item.attachment = attachment;
+      this.invalidateItem(item, name);
+      this.insertBefore(item, this.getItemAtIndex(index));
+      return item;
+    }
+
+    invalidateItem(item, name) {
+      let attachment = item.attachment;
+      item.setAttribute("name", name || attachment.name);
+      item
+        .querySelector(".attachmentcell-name")
+        .setAttribute("value", name || attachment.name);
+
       let size;
       if (attachment.size != null && attachment.size != -1) {
         size = this.messenger.formatFileSize(attachment.size);
       } else {
         // Use a zero-width space so the size label has the right height.
         size = "\u200b";
       }
       item.setAttribute("size", size);
-      sizeLabel.setAttribute("value", size);
+      item.querySelector(".attachmentcell-size").setAttribute("value", size);
 
       // Pick out some nice icons (small and large) for the attachment
       if (attachment.contentType == "text/x-moz-deleted") {
         let base = "chrome://messenger/skin/icons/";
         item.setAttribute("image16", base + "attachment-deleted.png");
         item.setAttribute("image32", base + "attachment-deleted-large.png");
       } else {
         let iconName = attachment.name;
@@ -1634,27 +1649,23 @@
           "image32",
           "moz-icon://" +
             iconName +
             "?size=32&contentType=" +
             attachment.contentType
         );
       }
 
-      let imageSize = this.sizes[this.getAttribute("view")] || 16;
-      item.setAttribute("imagesize", imageSize);
-      item.setAttribute("context", this.getAttribute("itemcontext"));
-      item.attachment = attachment;
-
-      let attr = "image" + imageSize;
+      let attr = "image" + item.getAttribute("imagesize");
       if (item.hasAttribute(attr)) {
-        icon.setAttribute("src", item.getAttribute(attr));
+        item
+          .querySelector(".attachmentcell-icon")
+          .setAttribute("src", item.getAttribute(attr));
       }
 
-      this.insertBefore(item, this.getItemAtIndex(index));
       return item;
     }
 
     /**
      * Find the attachmentitem node for the specified nsIMsgAttachment.
      */
     findItemForAttachment(aAttachment) {
       for (let i = 0; i < this.itemCount; i++) {
--- a/mail/components/compose/content/MsgComposeCommands.js
+++ b/mail/components/compose/content/MsgComposeCommands.js
@@ -2836,16 +2836,23 @@ attachmentWorker.onmessage = function(ev
  *
  * @param aShowPane {string} "show":  show the attachment pane
  *                           "hide":  hide the attachment pane
  *                           omitted: just update without changing pane visibility
  * @param aContentChanged {Boolean} optional value to assign to gContentChanged;
  *                                  defaults to true.
  */
 function AttachmentsChanged(aShowPane, aContentChanged = true) {
+  gAttachmentsSize = 0;
+  let bucket = document.getElementById("attachmentBucket");
+  for (let item of bucket.itemChildren) {
+    bucket.invalidateItem(item);
+    gAttachmentsSize += item.attachment.size;
+  }
+
   gContentChanged = aContentChanged;
   updateAttachmentPane(aShowPane);
   attachmentBucketMarkEmptyBucket();
   manageAttachmentNotification(true);
   updateAttachmentItems();
 }
 
 /**
@@ -5854,60 +5861,17 @@ function RemoveAllAttachments() {
   // Ensure that attachment pane is shown before removing all attachments.
   toggleAttachmentPane("show");
 
   let bucket = document.getElementById("attachmentBucket");
   if (bucket.itemCount == 0) {
     return;
   }
 
-  let fileHandler = Services.io
-    .getProtocolHandler("file")
-    .QueryInterface(Ci.nsIFileProtocolHandler);
-  let removedAttachments = Cc["@mozilla.org/array;1"].createInstance(
-    Ci.nsIMutableArray
-  );
-
-  while (bucket.itemCount > 0) {
-    let item = bucket.getItemAtIndex(bucket.itemCount - 1);
-    if (item.attachment.size != -1) {
-      gAttachmentsSize -= item.attachment.size;
-    }
-
-    if (item.attachment.sendViaCloud && item.cloudFileAccount) {
-      let originalUrl = item.originalUrl;
-      if (!originalUrl) {
-        originalUrl = item.attachment.url;
-      }
-      if (item.uploading) {
-        let file = fileHandler.getFileFromURLSpec(originalUrl);
-        item.cloudFileAccount.cancelFileUpload(file);
-      } else {
-        deleteCloudAttachment(
-          item.attachment,
-          item.cloudFileUpload.id,
-          item.cloudFileAccount
-        );
-      }
-    }
-
-    removedAttachments.appendElement(item.attachment);
-    // Let's release the attachment object hold by the node else it won't go
-    // away until the window is destroyed.
-    item.attachment = null;
-    item.remove();
-  }
-
-  if (removedAttachments.length > 0) {
-    // Bug workaround: Force update of selectedCount and selectedItem.
-    bucket.clearSelection();
-
-    AttachmentsChanged();
-    dispatchAttachmentBucketEvent("attachments-removed", removedAttachments);
-  }
+  RemoveAttachments(bucket.itemChildren);
 }
 
 /**
  * Show or hide the attachment pane after updating its header bar information
  * (number and total file size of attachments) and tooltip.
  *
  * @param aShowBucket {Boolean} true: show the attachment pane
  *                              false (or omitted): hide the attachment pane
@@ -5953,28 +5917,33 @@ function updateAttachmentPane(aShowPane)
 }
 
 function RemoveSelectedAttachment() {
   let bucket = GetMsgAttachmentElement();
   if (bucket.selectedCount == 0) {
     return;
   }
 
+  RemoveAttachments(bucket.selectedItems);
+}
+
+function RemoveAttachments(items) {
+  let bucket = document.getElementById("attachmentBucket");
   // Remember the current focus index so we can try to restore it when done.
   let focusIndex = bucket.currentIndex;
 
   let fileHandler = Services.io
     .getProtocolHandler("file")
     .QueryInterface(Ci.nsIFileProtocolHandler);
   let removedAttachments = Cc["@mozilla.org/array;1"].createInstance(
     Ci.nsIMutableArray
   );
 
-  for (let i = bucket.selectedCount - 1; i >= 0; i--) {
-    let item = bucket.getSelectedItem(i);
+  for (let i = items.length - 1; i >= 0; i--) {
+    let item = items[i];
     if (item.attachment.size != -1) {
       gAttachmentsSize -= item.attachment.size;
     }
 
     if (
       item.attachment.sendViaCloud &&
       item.cloudFileAccount &&
       (!item.cloudFileUpload || !item.cloudFileUpload.repeat)
@@ -5997,33 +5966,33 @@ function RemoveSelectedAttachment() {
 
     removedAttachments.appendElement(item.attachment);
     // Let's release the attachment object held by the node else it won't go
     // away until the window is destroyed
     item.attachment = null;
     item.remove();
   }
 
-  // Bug workaround: Force update of selectedCount and selectedItem, both wrong
-  // after item removal, to avoid confusion for listening command controllers.
-  bucket.clearSelection();
-
   // Try to restore original focus or somewhere close by.
-  if (focusIndex < bucket.itemCount) {
-    // If possible,
-    bucket.currentIndex = focusIndex; // restore focus at original position;
+  if (bucket.itemCount == 0) {
+    bucket.currentIndex = -1;
+  } else if (focusIndex < bucket.itemCount) {
+    bucket.currentIndex = focusIndex;
   } else {
-    bucket.currentIndex =
-      bucket.itemCount > 0 // else: if attachments exist,
-        ? bucket.itemCount - 1 // focus last item;
-        : -1; // else: nothing to focus.
-  }
-
-  AttachmentsChanged();
-  dispatchAttachmentBucketEvent("attachments-removed", removedAttachments);
+    bucket.currentIndex = bucket.itemCount - 1;
+  }
+
+  if (removedAttachments.length > 0) {
+    // Bug workaround: Force update of selectedCount and selectedItem, both wrong
+    // after item removal, to avoid confusion for listening command controllers.
+    bucket.clearSelection();
+
+    AttachmentsChanged();
+    dispatchAttachmentBucketEvent("attachments-removed", removedAttachments);
+  }
 }
 
 function RenameSelectedAttachment() {
   let bucket = document.getElementById("attachmentBucket");
   if (bucket.selectedItems.length != 1) {
     // Not one attachment selected.
     return;
   }
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/child/ext-compose.js
@@ -0,0 +1,119 @@
+/* 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/. */
+
+var { ExtensionError } = ExtensionUtils;
+
+/**
+ * Represents (in the child extension process) an attachment in a compose
+ * window. This is wrapped around an attachment object from the parent process
+ * in order to provide the getFile function, which lazily returns the content
+ * of the attachment.
+ *
+ * @param {ExtensionPageContextChild} context
+ *     The extension context which has registered the compose script.
+ * @param {object} attachment
+ *     The object provided by the parent extension process.
+ */
+class ComposeAttachment {
+  constructor(context, attachment) {
+    this.context = context;
+    this.attachment = attachment;
+  }
+
+  getFile() {
+    return this.context.childManager.callParentAsyncFunction(
+      "compose.getFile",
+      [this.attachment.id]
+    );
+  }
+
+  api() {
+    return {
+      id: this.attachment.id,
+      name: this.attachment.name,
+      getFile: () => {
+        return this.context.wrapPromise(this.getFile());
+      },
+    };
+  }
+}
+
+this.compose = class extends ExtensionAPI {
+  getAPI(context) {
+    return {
+      compose: {
+        onAttachmentAdded: new EventManager({
+          context,
+          name: "compose.onAttachmentAdded",
+          register(fire) {
+            let listener = (tab, attachment) => {
+              // We use the "without clone" version of this function since the
+              // ComposeAttachment argument has a function we need to clone,
+              // and the normal version clones without functions, throwing an
+              // error. This means we have to clone the arguments ourselves.
+              fire.asyncWithoutClone(
+                Cu.cloneInto(tab, context.cloneScope),
+                Cu.cloneInto(
+                  new ComposeAttachment(context, attachment).api(),
+                  context.cloneScope,
+                  { cloneFunctions: true }
+                )
+              );
+            };
+
+            let event = context.childManager.getParentEvent(
+              "compose.onAttachmentAdded"
+            );
+            event.addListener(listener);
+            return () => {
+              event.removeListener(listener);
+            };
+          },
+        }).api(),
+        listAttachments(tabId) {
+          return context.cloneScope.Promise.resolve().then(async () => {
+            let attachments = await context.childManager.callParentAsyncFunction(
+              "compose.listAttachments",
+              [tabId]
+            );
+
+            return Cu.cloneInto(
+              attachments.map(a => new ComposeAttachment(context, a).api()),
+              context.cloneScope,
+              { cloneFunctions: true }
+            );
+          });
+        },
+        addAttachment(tabId, data) {
+          return context.cloneScope.Promise.resolve().then(async () => {
+            let attachment = await context.childManager.callParentAsyncFunction(
+              "compose.addAttachment",
+              [tabId, data]
+            );
+
+            return Cu.cloneInto(
+              new ComposeAttachment(context, attachment).api(),
+              context.cloneScope,
+              { cloneFunctions: true }
+            );
+          });
+        },
+        updateAttachment(tabId, attachmentId, data) {
+          return context.cloneScope.Promise.resolve().then(async () => {
+            let attachment = await context.childManager.callParentAsyncFunction(
+              "compose.updateAttachment",
+              [tabId, attachmentId, data]
+            );
+
+            return Cu.cloneInto(
+              new ComposeAttachment(context, attachment).api(),
+              context.cloneScope,
+              { cloneFunctions: true }
+            );
+          });
+        },
+      },
+    };
+  }
+};
--- a/mail/components/extensions/child/ext-mail.js
+++ b/mail/components/extensions/child/ext-mail.js
@@ -1,15 +1,20 @@
 /* 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/. */
 
 "use strict";
 
 extensions.registerModules({
+  compose: {
+    url: "chrome://messenger/content/child/ext-compose.js",
+    scopes: ["addon_child"],
+    paths: [["compose"]],
+  },
   composeScripts: {
     url: "chrome://messenger/content/child/ext-composeScripts.js",
     scopes: ["addon_child"],
     paths: [["composeScripts"]],
   },
   menus: {
     url: "chrome://messenger/content/child/ext-menus.js",
     scopes: ["addon_child"],
--- a/mail/components/extensions/jar.mn
+++ b/mail/components/extensions/jar.mn
@@ -1,16 +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/.
 
 messenger.jar:
     content/messenger/ext-mail.json                (ext-mail.json)
     content/messenger/extension.svg                (extension.svg)
 
+    content/messenger/child/ext-compose.js         (child/ext-compose.js)
     content/messenger/child/ext-composeScripts.js  (child/ext-composeScripts.js)
     content/messenger/child/ext-mail.js            (child/ext-mail.js)
     content/messenger/child/ext-menus.js           (child/ext-menus.js)
     content/messenger/child/ext-tabs.js            (child/ext-tabs.js)
 
     content/messenger/parent/ext-accounts.js       (parent/ext-accounts.js)
     content/messenger/parent/ext-addressBook.js    (parent/ext-addressBook.js)
     content/messenger/parent/ext-browserAction.js  (parent/ext-browserAction.js)
--- a/mail/components/extensions/parent/.eslintrc.js
+++ b/mail/components/extensions/parent/.eslintrc.js
@@ -28,16 +28,17 @@ module.exports = {
     getCookieStoreIdForContainer: true,
     getCookieStoreIdForTab: true,
     isContainerCookieStoreId: true,
     isDefaultCookieStoreId: true,
     isPrivateCookieStoreId: true,
     isValidCookieStoreId: true,
 
     // These are defined in ext-mail.js.
+    COMPOSE_WINDOW_URI: true,
     ExtensionError: true,
     Tab: true,
     TabmailTab: true,
     Window: true,
     TabmailWindow: true,
     clickModifiersFromEvent: true,
     convertFolder: true,
     convertMailIdentity: true,
--- a/mail/components/extensions/parent/ext-compose.js
+++ b/mail/components/extensions/parent/ext-compose.js
@@ -2,16 +2,25 @@
  * 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, "OS", "resource://gre/modules/osfile.jsm");
+ChromeUtils.defineModuleGetter(
+  this,
+  "toXPCOMArray",
+  "resource:///modules/iteratorUtils.jsm"
+);
+
+// eslint-disable-next-line mozilla/reject-importGlobalProperties
+Cu.importGlobalProperties(["File", "FileReader"]);
 
 async function parseComposeRecipientList(list) {
   if (Array.isArray(list)) {
     let recipients = [];
     for (let recipient of list) {
       if (typeof recipient == "string") {
         recipients.push(recipient);
         continue;
@@ -43,20 +52,17 @@ async function parseComposeRecipientList
   }
   return list;
 }
 
 async function openComposeWindow(relatedMessageId, type, details, extension) {
   function waitForWindow() {
     return new Promise(resolve => {
       function observer(subject, topic, data) {
-        if (
-          subject.location.href ==
-          "chrome://messenger/content/messengercompose/messengercompose.xhtml"
-        ) {
+        if (subject.location.href == COMPOSE_WINDOW_URI) {
           Services.obs.removeObserver(observer, "chrome-document-loaded");
           resolve(subject.ownerGlobal);
         }
       }
       Services.obs.addObserver(observer, "chrome-document-loaded");
     });
   }
 
@@ -206,16 +212,44 @@ async function setComposeDetails(compose
     }
   }
   if (Array.isArray(details.newsgroups)) {
     details.newsgroups = details.newsgroups.join(",");
   }
   composeWindow.SetComposeDetails(details);
 }
 
+async function writeTempFile(file) {
+  let tempDir = OS.Constants.Path.tmpDir;
+  let destFile = OS.Path.join(tempDir, file.name);
+
+  let { path: outputPath, file: outputFileWriter } = await OS.File.openUnique(
+    destFile
+  );
+  let outputFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+  outputFile.initWithPath(outputPath);
+
+  let extAppLauncher = Cc["@mozilla.org/mime;1"].getService(
+    Ci.nsPIExternalAppLauncher
+  );
+  extAppLauncher.deleteTemporaryFileOnExit(outputFile);
+
+  return new Promise(function(resolve) {
+    let reader = new FileReader();
+    reader.onloadend = async function() {
+      await outputFileWriter.write(new Uint8Array(reader.result));
+      outputFileWriter.close();
+
+      let outputURL = Services.io.newFileURI(outputFile);
+      resolve(outputURL.spec);
+    };
+    reader.readAsArrayBuffer(file);
+  });
+}
+
 var composeEventTracker = {
   listeners: new Set(),
 
   addListener(listener) {
     this.listeners.add(listener);
     if (this.listeners.size == 1) {
       windowTracker.addListener("beforesend", this);
     }
@@ -259,28 +293,84 @@ var composeEventTracker = {
     }
     // Calling getComposeDetails collapses mailing lists. Expand them again.
     composeWindow.expandRecipients();
     composeWindow.ToggleWindowLock(false);
     composeWindow.CompleteGenericSendMessage(msgType);
   },
 };
 
+var composeAttachmentTracker = {
+  _nextId: 1,
+  _attachments: new Map(),
+  _attachmentIds: new Map(),
+
+  getId(attachment, window) {
+    if (this._attachmentIds.has(attachment)) {
+      return this._attachmentIds.get(attachment).id;
+    }
+    let id = this._nextId++;
+    this._attachments.set(id, { attachment, window });
+    this._attachmentIds.set(attachment, { id, window });
+    return id;
+  },
+
+  getAttachment(id) {
+    return this._attachments.get(id);
+  },
+
+  hasAttachment(id) {
+    return this._attachments.has(id);
+  },
+
+  forgetAttachment(attachment) {
+    let id = this._attachmentIds.get(attachment).id;
+    this._attachmentIds.delete(attachment);
+    if (id) {
+      this._attachments.delete(id);
+    }
+  },
+
+  forgetAttachments(window) {
+    if (window.location.href == COMPOSE_WINDOW_URI) {
+      let bucket = window.document.getElementById("attachmentBucket");
+      for (let item of bucket.itemChildren) {
+        this.forgetAttachment(item.attachment);
+      }
+    }
+  },
+
+  async convert(attachment, window) {
+    return {
+      id: this.getId(attachment, window),
+      name: attachment.name,
+    };
+  },
+
+  getFile(id) {
+    let { attachment } = this.getAttachment(id);
+    if (!attachment) {
+      return null;
+    }
+
+    let uri = Services.io.newURI(attachment.url).QueryInterface(Ci.nsIFileURL);
+    return File.createFromNsIFile(uri.file);
+  },
+};
+windowTracker.addCloseListener(composeAttachmentTracker.forgetAttachments);
+
 this.compose = class extends ExtensionAPI {
   getAPI(context) {
     function getComposeTab(tabId) {
       let tab = tabManager.get(tabId);
       if (tab instanceof TabmailTab) {
         throw new ExtensionError("Not a valid compose window");
       }
       let location = tab.nativeTab.location.href;
-      if (
-        location !=
-        "chrome://messenger/content/messengercompose/messengercompose.xhtml"
-      ) {
+      if (location != COMPOSE_WINDOW_URI) {
         throw new ExtensionError(`Not a valid compose window: ${location}`);
       }
       return tab;
     }
 
     let { extension } = context;
     let { tabManager, windowManager } = extension;
 
@@ -303,16 +393,67 @@ this.compose = class extends ExtensionAP
             };
 
             composeEventTracker.addListener(listener);
             return () => {
               composeEventTracker.removeListener(listener);
             };
           },
         }).api(),
+        onAttachmentAdded: new ExtensionCommon.EventManager({
+          context,
+          name: "compose.onAttachmentAdded",
+          register(fire) {
+            async function callback(event) {
+              for (let attachment of event.detail.enumerate(
+                Ci.nsIMsgAttachment
+              )) {
+                attachment = await composeAttachmentTracker.convert(
+                  attachment,
+                  event.target.ownerGlobal
+                );
+                fire.async(
+                  tabManager.convert(event.target.ownerGlobal),
+                  attachment
+                );
+              }
+            }
+
+            windowTracker.addListener("attachments-added", callback);
+            return function() {
+              windowTracker.removeListener("attachments-added", callback);
+            };
+          },
+        }).api(),
+        onAttachmentRemoved: new ExtensionCommon.EventManager({
+          context,
+          name: "compose.onAttachmentRemoved",
+          register(fire) {
+            function callback(event, tab, attachmentId) {
+              for (let attachment of event.detail.enumerate(
+                Ci.nsIMsgAttachment
+              )) {
+                let attachmentId = composeAttachmentTracker.getId(
+                  attachment,
+                  event.target.ownerGlobal
+                );
+                fire.async(
+                  tabManager.convert(event.target.ownerGlobal),
+                  attachmentId
+                );
+                composeAttachmentTracker.forgetAttachment(attachment);
+              }
+            }
+
+            windowTracker.addListener("attachments-removed", callback);
+            return function() {
+              windowTracker.removeListener("attachments-removed", callback);
+            };
+          },
+        }).api(),
         async beginNew(details) {
           let composeWindow = await openComposeWindow(
             null,
             Ci.nsIMsgCompType.New,
             details,
             extension
           );
           return tabManager.convert(composeWindow);
@@ -353,12 +494,103 @@ this.compose = class extends ExtensionAP
         getComposeDetails(tabId) {
           let tab = getComposeTab(tabId);
           return getComposeDetails(tab.nativeTab, extension);
         },
         setComposeDetails(tabId, details) {
           let tab = getComposeTab(tabId);
           return setComposeDetails(tab.nativeTab, details, extension);
         },
+        async listAttachments(tabId) {
+          let tab = tabManager.get(tabId);
+          if (!tab.isComposeTab) {
+            throw new ExtensionError(`Invalid compose tab: ${tabId}`);
+          }
+          let bucket = tab.nativeTab.document.getElementById(
+            "attachmentBucket"
+          );
+          let attachments = [];
+          for (let item of bucket.itemChildren) {
+            attachments.push(
+              await composeAttachmentTracker.convert(item.attachment, tab.nativeTab)
+            );
+          }
+          return attachments;
+        },
+        async addAttachment(tabId, data) {
+          let tab = tabManager.get(tabId);
+          if (!tab.isComposeTab) {
+            throw new ExtensionError(`Invalid compose tab: ${tabId}`);
+          }
+
+          let attachment = Cc[
+            "@mozilla.org/messengercompose/attachment;1"
+          ].createInstance(Ci.nsIMsgAttachment);
+          attachment.name = data.name || data.file.name;
+          attachment.size = data.file.size;
+          attachment.url = await writeTempFile(data.file);
+
+          tab.nativeTab.AddAttachments([attachment]);
+
+          return composeAttachmentTracker.convert(attachment);
+        },
+        async updateAttachment(tabId, attachmentId, data) {
+          let tab = tabManager.get(tabId);
+          if (!tab.isComposeTab) {
+            throw new ExtensionError(`Invalid compose tab: ${tabId}`);
+          }
+          if (!composeAttachmentTracker.hasAttachment(attachmentId)) {
+            throw new ExtensionError(`Invalid attachment: ${attachmentId}`);
+          }
+          let { attachment, window } = composeAttachmentTracker.getAttachment(
+            attachmentId
+          );
+          if (window != tab.nativeTab) {
+            throw new ExtensionError(
+              `Attachment ${attachmentId} is not associated with tab ${tabId}`
+            );
+          }
+
+          if (data.name) {
+            attachment.name = data.name;
+          }
+          if (data.file) {
+            attachment.size = data.file.size;
+            attachment.url = await writeTempFile(data.file);
+          }
+
+          window.AttachmentsChanged();
+          return composeAttachmentTracker.convert(attachment);
+        },
+        async removeAttachment(tabId, attachmentId) {
+          let tab = tabManager.get(tabId);
+          if (!tab.isComposeTab) {
+            throw new ExtensionError(`Invalid compose tab: ${tabId}`);
+          }
+          if (!composeAttachmentTracker.hasAttachment(attachmentId)) {
+            throw new ExtensionError(`Invalid attachment: ${attachmentId}`);
+          }
+          let { attachment, window } = composeAttachmentTracker.getAttachment(
+            attachmentId
+          );
+          if (window != tab.nativeTab) {
+            throw new ExtensionError(
+              `Attachment ${attachmentId} is not associated with tab ${tabId}`
+            );
+          }
+
+          let bucket = window.document.getElementById("attachmentBucket");
+          let item = bucket.findItemForAttachment(attachment);
+          item.remove();
+
+          window.RemoveAttachments([item]);
+        },
+
+        // This method is not available to the extension code, the extension
+        // code will call .getFile() on the object that is resolved from
+        // promises returned by various API methods.
+        getFile(attachmentId) {
+          return composeAttachmentTracker.getFile(attachmentId);
+        },
       },
     };
   }
 };
--- a/mail/components/extensions/schemas/compose.json
+++ b/mail/components/extensions/schemas/compose.json
@@ -117,16 +117,40 @@
             "type": "string",
             "optional": true
           },
           "isPlainText": {
             "type": "boolean",
             "optional": true
           }
         }
+      },
+      {
+        "id": "ComposeAttachment",
+        "type": "object",
+        "description": "Represents an attachment in a message being composed.",
+        "properties": {
+          "id": {
+            "type": "integer",
+            "description": "A unique identifier for this attachment."
+          },
+          "name": {
+            "type": "string",
+            "description": "The name, as displayed to the user, of this attachment. This is usually but not always the filename of the attached file."
+          }
+        },
+        "functions": [
+          {
+            "name": "getFile",
+            "type": "function",
+            "description": "Retrieves the contents of the attachment as a DOM ``File`` object.",
+            "async": true,
+            "parameters": []
+          }
+        ]
       }
     ],
     "events": [
       {
         "name": "onBeforeSend",
         "type": "function",
         "description": "Fired when a message is about to be sent from the compose window.",
         "permissions": [
@@ -153,16 +177,46 @@
             },
             "details": {
               "$ref": "ComposeDetails",
               "optional": true,
               "description": "Updates the compose window. See the :ref:`compose.setComposeDetails` function for more information."
             }
           }
         }
+      },
+      {
+        "name": "onAttachmentAdded",
+        "type": "function",
+        "description": "Fired when an attachment is added to a message being composed.",
+        "parameters": [
+          {
+            "name": "tab",
+            "$ref": "tabs.Tab"
+          },
+          {
+            "name": "attachment",
+            "$ref": "ComposeAttachment"
+          }
+        ]
+      },
+      {
+        "name": "onAttachmentRemoved",
+        "type": "function",
+        "description": "Fired when an attachment is removed from a message being composed.",
+        "parameters": [
+          {
+            "name": "tab",
+            "$ref": "tabs.Tab"
+          },
+          {
+            "name": "attachmentId",
+            "type": "integer"
+          }
+        ]
       }
     ],
     "functions": [
       {
         "name": "beginNew",
         "type": "function",
         "async": true,
         "parameters": [
@@ -258,12 +312,101 @@
             "name": "tabId",
             "minimum": 0
           },
           {
             "name": "details",
             "$ref": "ComposeDetails"
           }
         ]
+      },
+      {
+        "name": "listAttachments",
+        "type": "function",
+        "description": "Lists all of the attachments of the message being composed in the specified tab.",
+        "async": true,
+        "parameters": [
+          {
+            "name": "tabId",
+            "type": "integer"
+          }
+        ]
+      },
+      {
+        "name": "addAttachment",
+        "type": "function",
+        "description": "Adds an attachment to the message being composed in the specified tab.",
+        "async": true,
+        "parameters": [
+          {
+            "name": "tabId",
+            "type": "integer"
+          },
+          {
+            "name": "data",
+            "type": "object",
+            "properties": {
+              "name": {
+                "type": "string",
+                "description": "The name, as displayed to the user, of this attachment. If not specified, the name of the ``file`` object is used.",
+                "optional": true
+              },
+              "file": {
+                "type": "object",
+                "isInstanceOf": "File",
+                "additionalProperties": true
+              }
+            }
+          }
+        ]
+      },
+      {
+        "name": "updateAttachment",
+        "type": "function",
+        "description": "Renames and/or replaces the contents of an attachment to the message being composed in the specified tab.",
+        "async": true,
+        "parameters": [
+          {
+            "name": "tabId",
+            "type": "integer"
+          },
+          {
+            "name": "attachmentId",
+            "type": "integer"
+          },
+          {
+            "name": "data",
+            "type": "object",
+            "properties": {
+              "name": {
+                "type": "string",
+                "description": "The name, as displayed to the user, of this attachment. If not specified, the name of the ``file`` object is used.",
+                "optional": true
+              },
+              "file": {
+                "type": "object",
+                "isInstanceOf": "File",
+                "additionalProperties": true,
+                "optional": true
+              }
+            }
+          }
+        ]
+      },
+      {
+        "name": "removeAttachment",
+        "type": "function",
+        "description": "Removes an attachment from the message being composed in the specified tab.",
+        "async": true,
+        "parameters": [
+          {
+            "name": "tabId",
+            "type": "integer"
+          },
+          {
+            "name": "attachmentId",
+            "type": "integer"
+          }
+        ]
       }
     ]
   }
 ]
--- a/mail/components/extensions/test/browser/browser.ini
+++ b/mail/components/extensions/test/browser/browser.ini
@@ -14,16 +14,17 @@ tags = webextensions
 [browser_ext_addressBooksUI.js]
 tags = addrbook
 [browser_ext_browserAction.js]
 [browser_ext_browserAction_properties.js]
 [browser_ext_commands_execute_browser_action.js]
 [browser_ext_commands_getAll.js]
 [browser_ext_commands_onCommand.js]
 [browser_ext_commands_update.js]
+[browser_ext_compose_attachments.js]
 [browser_ext_compose_begin.js]
 [browser_ext_compose_details.js]
 [browser_ext_compose_onBeforeSend.js]
 [browser_ext_composeAction.js]
 [browser_ext_composeAction_properties.js]
 [browser_ext_composeScripts.js]
 [browser_ext_mailTabs.js]
 [browser_ext_menus.js]
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/test/browser/browser_ext_compose_attachments.js
@@ -0,0 +1,229 @@
+/* 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/. */
+
+addIdentity(createAccount());
+
+add_task(async function() {
+  let extension = ExtensionTestUtils.loadExtension({
+    background: async () => {
+      let listener = {
+        events: [],
+        currentPromise: null,
+
+        pushEvent(...args) {
+          browser.test.log(JSON.stringify(args));
+          this.events.push(args);
+          if (this.currentPromise) {
+            let p = this.currentPromise;
+            this.currentPromise = null;
+            p.resolve();
+          }
+        },
+        async checkEvent(expectedEvent, ...expectedArgs) {
+          if (this.events.length == 0) {
+            await new Promise(resolve => (this.currentPromise = { resolve }));
+          }
+          let [actualEvent, ...actualArgs] = this.events.shift();
+          browser.test.assertEq(expectedEvent, actualEvent);
+          browser.test.assertEq(expectedArgs.length, actualArgs.length);
+
+          for (let i = 0; i < expectedArgs.length; i++) {
+            browser.test.assertEq(typeof expectedArgs[i], typeof actualArgs[i]);
+            if (typeof expectedArgs[i] == "object") {
+              for (let key of Object.keys(expectedArgs[i])) {
+                browser.test.assertEq(expectedArgs[i][key], actualArgs[i][key]);
+              }
+            } else {
+              browser.test.assertEq(expectedArgs[i], actualArgs[i]);
+            }
+          }
+
+          return actualArgs;
+        },
+      };
+      browser.compose.onAttachmentAdded.addListener((...args) =>
+        listener.pushEvent("onAttachmentAdded", ...args)
+      );
+      browser.compose.onAttachmentRemoved.addListener((...args) =>
+        listener.pushEvent("onAttachmentRemoved", ...args)
+      );
+
+      let checkData = async (attachment, size) => {
+        browser.test.assertEq("function", typeof attachment.getFile);
+        let data = await attachment.getFile();
+        browser.test.assertTrue(data instanceof File);
+        browser.test.assertEq(size, data.size);
+      };
+
+      let checkUI = async function(...expected) {
+        let attachments = await browser.compose.listAttachments(composeTab.id);
+        browser.test.assertEq(expected.length, attachments.length);
+        for (let i = 0; i < expected.length; i++) {
+          browser.test.assertEq(expected[i].id, attachments[i].id);
+        }
+
+        return new Promise(resolve => {
+          browser.test.onMessage.addListener(function listener() {
+            browser.test.onMessage.removeListener(listener);
+            resolve();
+          });
+          browser.test.sendMessage("checkUI", expected);
+        });
+      };
+
+      let file1 = new File(["File number one!"], "file1.txt");
+      let file2 = new File(
+        ["File number two? Yes, this is number two."],
+        "file2.txt"
+      );
+      let file3 = new File(["I'm pretending to be file two."], "file3.txt");
+      let composeTab = await browser.compose.beginNew();
+
+      await checkUI();
+
+      // Add an attachment.
+
+      let attachment1 = await browser.compose.addAttachment(composeTab.id, {
+        file: file1,
+      });
+      browser.test.assertEq("file1.txt", attachment1.name);
+      await checkData(attachment1, file1.size);
+
+      let [, added1] = await listener.checkEvent(
+        "onAttachmentAdded",
+        { id: composeTab.id },
+        { id: attachment1.id, name: "file1.txt" }
+      );
+      await checkData(added1, file1.size);
+
+      await checkUI({
+        id: attachment1.id,
+        name: "file1.txt",
+        size: file1.size,
+      });
+
+      // Add another attachment.
+
+      let attachment2 = await browser.compose.addAttachment(composeTab.id, {
+        file: file2,
+        name: "this is file2.txt",
+      });
+      browser.test.assertEq("this is file2.txt", attachment2.name);
+      await checkData(attachment2, file2.size);
+
+      let [, added2] = await listener.checkEvent(
+        "onAttachmentAdded",
+        { id: composeTab.id },
+        { id: attachment2.id, name: "this is file2.txt" }
+      );
+      await checkData(added2, file2.size);
+
+      await checkUI(
+        { id: attachment1.id, name: "file1.txt", size: file1.size },
+        { id: attachment2.id, name: "this is file2.txt", size: file2.size }
+      );
+
+      // Change an attachment.
+
+      let changed2 = await browser.compose.updateAttachment(
+        composeTab.id,
+        attachment2.id,
+        { name: "file2 with a new name.txt" }
+      );
+      browser.test.assertEq("file2 with a new name.txt", changed2.name);
+      await checkData(changed2, file2.size);
+
+      await checkUI(
+        { id: attachment1.id, name: "file1.txt", size: file1.size },
+        {
+          id: attachment2.id,
+          name: "file2 with a new name.txt",
+          size: file2.size,
+        }
+      );
+
+      let changed3 = await browser.compose.updateAttachment(
+        composeTab.id,
+        attachment2.id,
+        { file: file3 }
+      );
+      browser.test.assertEq("file2 with a new name.txt", changed3.name);
+      await checkData(changed3, file3.size);
+
+      await checkUI(
+        { id: attachment1.id, name: "file1.txt", size: file1.size },
+        {
+          id: attachment2.id,
+          name: "file2 with a new name.txt",
+          size: file3.size,
+        }
+      );
+
+      // Remove the first attachment.
+
+      await browser.compose.removeAttachment(composeTab.id, attachment1.id);
+      await listener.checkEvent(
+        "onAttachmentRemoved",
+        { id: composeTab.id },
+        attachment1.id
+      );
+
+      await checkUI({
+        id: attachment2.id,
+        name: "file2 with a new name.txt",
+        size: file3.size,
+      });
+
+      // Remove the second attachment.
+
+      await browser.compose.removeAttachment(composeTab.id, attachment2.id);
+      await listener.checkEvent(
+        "onAttachmentRemoved",
+        { id: composeTab.id },
+        attachment2.id
+      );
+
+      await checkUI();
+
+      await browser.tabs.remove(composeTab.id);
+      browser.test.assertEq(0, listener.events.length);
+      browser.test.notifyPass("finished");
+    },
+    manifest: { permissions: ["compose"] },
+  });
+
+  extension.onMessage("checkUI", expected => {
+    let composeWindow = Services.wm.getMostRecentWindow("msgcompose");
+    let composeDocument = composeWindow.document;
+
+    let bucket = composeDocument.getElementById("attachmentBucket");
+    Assert.equal(bucket.itemCount, expected.length);
+
+    let totalSize = 0;
+    for (let i = 0; i < expected.length; i++) {
+      let { name, size } = expected[i];
+      totalSize += size;
+
+      let item = bucket.itemChildren[i];
+      Assert.equal(item.querySelector(".attachmentcell-name").value, name);
+      Assert.equal(
+        item.querySelector(".attachmentcell-size").value,
+        `${size} bytes`
+      );
+    }
+
+    let bucketTotal = composeDocument.getElementById("attachmentBucketSize");
+    if (totalSize == 0) {
+      Assert.equal(bucketTotal.value, "");
+    } else {
+      Assert.equal(bucketTotal.value, `${totalSize} bytes`);
+    }
+
+    extension.sendMessage();
+  });
+
+  await extension.startup();
+  await extension.awaitFinish("finished");
+  await extension.unload();
+});