Bug 1481052 - FileLink WebExtensions API; r=Fallen
authorGeoff Lankow <geoff@darktrojan.net>
Thu, 29 Nov 2018 12:04:41 +1300
changeset 33808 8709d04aee9295aea108b7e20823b550d5d74017
parent 33807 f43611e42a795fadea6cd6d4695929588152098b
child 33809 f5c224fa2c5f37bd2ee63aeb71e150dd44ee86d0
push id388
push userclokep@gmail.com
push dateMon, 28 Jan 2019 20:54:56 +0000
reviewersFallen
bugs1481052
Bug 1481052 - FileLink WebExtensions API; r=Fallen
mail/components/cloudfile/cloudFileAccounts.js
mail/components/cloudfile/content/addAccountDialog.js
mail/components/compose/content/MsgComposeCommands.js
mail/components/extensions/ext-mail.json
mail/components/extensions/jar.mn
mail/components/extensions/parent/ext-cloudFile.js
mail/components/extensions/schemas/cloudFile.json
mail/components/extensions/test/xpcshell/data/cloudFile1.txt
mail/components/extensions/test/xpcshell/data/cloudFile2.txt
mail/components/extensions/test/xpcshell/test_ext_cloudFile.js
mail/components/extensions/test/xpcshell/xpcshell.ini
mail/components/preferences/applications.js
mail/themes/linux/mail/cloudfile/addAccountDialog.css
mail/themes/osx/mail/cloudfile/addAccountDialog.css
mail/themes/windows/mail/cloudfile/addAccountDialog.css
--- a/mail/components/cloudfile/cloudFileAccounts.js
+++ b/mail/components/cloudfile/cloudFileAccounts.js
@@ -11,110 +11,162 @@ var ACCOUNT_ROOT = PREF_ROOT + "accounts
 // The following constants are used to query and insert entries
 // into the nsILoginManager.
 var PWDMGR_HOST = "chrome://messenger/cloudfile";
 var PWDMGR_REALM = "BigFiles Auth Token";
 
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 const { fixIterator } = ChromeUtils.import("resource:///modules/iteratorUtils.jsm", null);
+ChromeUtils.import("resource://gre/modules/EventEmitter.jsm");
 
-var cloudFileAccounts = {
+var cloudFileAccounts = new class extends EventEmitter {
+  constructor() {
+    super();
+    this._providers = new Map();
+  }
+
   get kTokenRealm() {
     return PWDMGR_REALM;
-  },
+  }
 
   get _accountKeys() {
     let accountKeySet = {};
     let branch = Services.prefs.getBranch(ACCOUNT_ROOT);
     let children = branch.getChildList("", {});
     for (let child of children) {
       let subbranch = child.substr(0, child.indexOf("."));
       accountKeySet[subbranch] = 1;
     }
 
     // TODO: sort by ordinal
     return Object.keys(accountKeySet);
-  },
+  }
 
   _getInitedProviderForType(aAccountKey, aType) {
     let provider = this.getProviderForType(aType);
     if (provider) {
       try {
         provider.init(aAccountKey);
       } catch (e) {
         Cu.reportError(e);
         provider = null;
       }
     }
     return provider;
-  },
+  }
 
   _createUniqueAccountKey() {
     // Pick a unique account key (TODO: this is a dumb way to do it, probably)
     let existingKeys = this._accountKeys;
     for (let n = 1; ; n++) {
 
       if (!existingKeys.includes("account" + n))
         return "account" + n;
     }
-  },
+  }
 
   /**
    * Ensure that we have the account key for an account. If we already have the
    * key, just return it. If we have the nsIMsgCloudFileProvider, get the key
    * from it.
    *
    * @param aKeyOrAccount the key or the account object
    * @return the account key
    */
   _ensureKey(aKeyOrAccount) {
     if (typeof aKeyOrAccount == "string")
       return aKeyOrAccount;
     if ("accountKey" in aKeyOrAccount)
       return aKeyOrAccount.accountKey;
     throw new Error("string or nsIMsgCloudFileProvider expected");
-  },
+  }
+
+  /**
+   * Register a cloudfile provider, e.g. from a bootstrapped add-on. Registering can be done in two
+   * ways, either implicitly through using the "cloud-files" XPCOM category, or explicitly using
+   * this function.
+   *
+   * @param {nsIMsgCloudFileProvider} The implementation to register
+   */
+  registerProvider(aProvider) {
+    let type = aProvider.type;
+    let hasXPCOM = false;
+
+    try {
+      Services.catMan.getCategoryEntry(CATEGORY, type);
+      hasXPCOM = true;
+    } catch (ex) {
+    }
+
+    if (this._providers.has(type)) {
+      throw new Error(`Cloudfile provider ${type} is already registered`);
+    } else if (hasXPCOM) {
+      throw new Error(`Cloudfile provider ${type} is already registered as an XPCOM component`);
+    }
+    this._providers.set(aProvider.type, aProvider);
+  }
+
+  /**
+   * Unregister a cloudfile provider. This function will only unregister those providers registered
+   * through #registerProvider. XPCOM providers cannot be unregistered here.
+   *
+   * @param {String} aType                  The provider type to unregister
+   */
+  unregisterProvider(aType) {
+    if (!this._providers.has(aType)) {
+      throw new Error(`Cloudfile provider ${aType} is not registered`);
+    }
+
+    this._providers.delete(aType);
+  }
 
   getProviderForType(aType) {
+    if (this._providers.has(aType)) {
+      return this._providers.get(aType);
+    }
+
     try {
-      let className = categoryManager.getCategoryEntry(CATEGORY, aType);
+      let className = Services.catMan.getCategoryEntry(CATEGORY, aType);
       let provider = Cc[className].createInstance(Ci.nsIMsgCloudFileProvider);
       return provider;
     } catch (e) {
       if (e.result != Cr.NS_ERROR_NOT_AVAILABLE) {
         // If a provider is not available we swallow the error message.
         // Otherwise at least notify, so developers can fix things.
         Cu.reportError("Getting provider for type=" + aType + " FAILED; " + e);
       }
     }
+
     return null;
-  },
+  }
 
   // aExtraPrefs are prefs specific to an account provider.
   createAccount(aType, aRequestObserver, aExtraPrefs) {
     let key = this._createUniqueAccountKey();
 
     try {
       Services.prefs
               .setCharPref(ACCOUNT_ROOT + key + ".type", aType);
 
       if (aExtraPrefs !== undefined)
         this._processExtraPrefs(key, aExtraPrefs);
 
       let provider = this._getInitedProviderForType(key, aType);
-      if (provider)
+      if (provider) {
         provider.createExistingAccount(aRequestObserver);
+        this.emit("accountAdded", provider);
+      }
 
       return provider;
     } catch (e) {
       Services.prefs.deleteBranch(ACCOUNT_ROOT + key);
       throw e;
     }
-  },
+  }
 
   // Set provider-specific prefs
   _processExtraPrefs(aAccountKey, aExtraPrefs) {
     const kFuncMap = {
       "int": "setIntPref",
       "bool": "setBoolPref",
       "char": "setCharPref",
     };
@@ -127,90 +179,97 @@ var cloudFileAccounts = {
         Cu.reportError("Did not recognize type: " + type);
         continue;
       }
 
       let func = kFuncMap[type];
       Services.prefs[func](ACCOUNT_ROOT + aAccountKey + "." + prefKey,
                            value);
     }
-  },
+  }
 
   * enumerateProviders() {
-    for (let entry of fixIterator(categoryManager.enumerateCategory(CATEGORY),
+    for (let [type, provider] of this._providers.entries()) {
+      yield [type, provider];
+    }
+
+    for (let entry of fixIterator(Services.catMan.enumerateCategory(CATEGORY),
                                   Ci.nsISupportsCString)) {
       let provider = this.getProviderForType(entry.data);
       yield [entry.data, provider];
     }
-  },
+  }
 
   getAccount(aKey) {
     let type = Services.prefs.getCharPref(ACCOUNT_ROOT + aKey + ".type");
     return this._getInitedProviderForType(aKey, type);
-  },
+  }
 
   removeAccount(aKeyOrAccount) {
     let key = this._ensureKey(aKeyOrAccount);
+    let type = Services.prefs.getCharPref(ACCOUNT_ROOT + key + ".type");
 
     Services.prefs.deleteBranch(ACCOUNT_ROOT + key);
 
     // Destroy any secret tokens for this accountKey.
     let logins = Services.logins
                          .findLogins({}, PWDMGR_HOST, null, "");
     for (let login of logins) {
       if (login.username == key)
         Services.logins.removeLogin(login);
     }
-  },
+
+    this.emit("accountDeleted", key, type);
+  }
 
   get accounts() {
     return this._accountKeys.filter(key => this.getAccount(key) != null).
       map(key => this.getAccount(key));
-  },
+  }
 
   getAccountsForType(aType) {
     let result = [];
 
     for (let accountKey of this._accountKeys) {
       let type = Services.prefs.getCharPref(ACCOUNT_ROOT + accountKey + ".type");
       if (type === aType)
         result.push(this.getAccount(accountKey));
     }
 
     return result;
-  },
+  }
 
   addAccountDialog() {
     let params = {accountKey: null};
     Services.wm
             .getMostRecentWindow(null)
             .openDialog("chrome://messenger/content/cloudfile/"
                         + "addAccountDialog.xul",
                         "", "chrome, dialog, modal, resizable=yes",
                         params).focus();
     return params.accountKey;
-  },
+  }
 
   getDisplayName(aKeyOrAccount) {
     try {
       let key = this._ensureKey(aKeyOrAccount);
       return Services.prefs.getCharPref(ACCOUNT_ROOT +
                                         key + ".displayName");
     } catch (e) {
       // If no display name has been set, we return the empty string.
       Cu.reportError(e);
       return "";
     }
-  },
+  }
 
   setDisplayName(aKeyOrAccount, aDisplayName) {
     let key = this._ensureKey(aKeyOrAccount);
     Services.prefs.setCharPref(ACCOUNT_ROOT + key +
                                ".displayName", aDisplayName);
-  },
+  }
 
   /**
    * Retrieve a secret value, like an authorization token, for an account.
    *
    * @param aKeyOrAccount an nsIMsgCloudFileProvider, or an accountKey
    *                      for a provider.
    * @param aRealm a human-readable string describing what exactly
    *               was being stored. Should match the realm used when setting
@@ -220,17 +279,17 @@ var cloudFileAccounts = {
     let key = this._ensureKey(aKeyOrAccount);
 
     let loginInfo = this._getLoginInfoForKey(key, aRealm);
 
     if (loginInfo)
       return loginInfo.password;
 
     return null;
-  },
+  }
 
   /**
    * Store a secret value, like an authorization token, for an account
    * in nsILoginManager.
    *
    * @param aKeyOrAccount an nsIMsgCloudFileProvider, or an accountKey
    *                      for a provider.
    * @param aRealm a human-readable string describing what exactly
@@ -256,17 +315,17 @@ var cloudFileAccounts = {
                        .createInstance(Ci.nsILoginInfo);
     newLoginInfo.init(PWDMGR_HOST, null, aRealm, key,
                       aToken, "", "");
 
     if (loginInfo)
       Services.logins.modifyLogin(loginInfo, newLoginInfo);
     else
       Services.logins.addLogin(newLoginInfo);
-  },
+  }
 
   /**
    * Searches the nsILoginManager for an nsILoginInfo for BigFiles with
    * the username set to aKey, and the realm set to aRealm.
    *
    * @param aKey a key for an nsIMsgCloudFileProvider that we're searching
    *             for login info for.
    * @param aRealm the realm that the login info was stored under.
@@ -274,14 +333,10 @@ var cloudFileAccounts = {
   _getLoginInfoForKey(aKey, aRealm) {
     let logins = Services.logins
                          .findLogins({}, PWDMGR_HOST, null, aRealm);
     for (let login of logins) {
       if (login.username == aKey)
         return login;
     }
     return null;
-  },
+  }
 };
-
-XPCOMUtils.defineLazyServiceGetter(this, "categoryManager",
-                                   "@mozilla.org/categorymanager;1",
-                                   "nsICategoryManager");
--- a/mail/components/cloudfile/content/addAccountDialog.js
+++ b/mail/components/cloudfile/content/addAccountDialog.js
@@ -12,26 +12,26 @@ ChromeUtils.import("resource:///modules/
 
 function createAccountObserver() {}
 
 createAccountObserver.prototype = {
   QueryInterface: ChromeUtils.generateQI([Ci.nsIRequestObserver]),
   onStartRequest(aRequest, aContext) {},
   onStopRequest(aRequest, aContext, aStatusCode) {
     if (aStatusCode == Cr.NS_OK
-        && aContext instanceof Ci.nsIMsgCloudFileProvider) {
+        && aContext.QueryInterface(Ci.nsIMsgCloudFileProvider)) {
       let accountKey = aContext.accountKey;
 
       // For now, we'll just set the display name to be the name of the service
       cloudFileAccounts.setDisplayName(accountKey, aContext.displayName);
 
       window.arguments[0].accountKey = aContext.accountKey;
       window.close();
     } else {
-      if (aContext instanceof Ci.nsIMsgCloudFileProvider) {
+      if (aContext.QueryInterface(Ci.nsIMsgCloudFileProvider)) {
         cloudFileAccounts.removeAccount(aContext.accountKey);
       } else {
         // Something went seriously wrong here...
         Cu.reportError("Cloud account creation failed, and " +
                        "provider instance missing!");
       }
 
       addAccountDialog._accept.disabled = false;
@@ -60,38 +60,34 @@ var addAccountDialog = {
     this._cancel = document.documentElement.getButton("cancel");
     this._messages = document.getElementById("messages");
     this._authSpinner = document.getElementById("authorizing");
     this._error = document.getElementById("error");
     this._createAccountText = document.getElementById("createAccountText");
 
     this.removeTitleMenuItem();
 
-    // Determine whether any account types were added to the menulist,
-    // if not, return early.
-    if (this.addAccountTypes() == 0)
-      return;
-
-    // Hook up our onInput event handler
-    this._settings.addEventListener("DOMContentLoaded", this);
-
-    this._settings.addEventListener("overflow", this);
-
-    // Hook up the selection handler.
-    this._accountType.addEventListener("select", this);
-    // Also call it to run it for the default selection.
-    addAccountDialog.accountTypeSelected();
-
     // Hook up the default "Learn More..." link to the appropriate link.
     let learnMore = this._settings
                         .contentDocument
                         .querySelector('#learn-more > a[href=""]');
     if (learnMore)
       learnMore.href = Services.prefs
                                .getCharPref("mail.cloud_files.learn_more_url");
+
+    // Determine whether any account types were added to the menulist,
+    // if not, return early.
+    if (this.addAccountTypes() == 0)
+      return;
+
+    // Hook up the selection handler.
+    this._accountType.addEventListener("select", this);
+    // Also call it to run it for the default selection.
+    addAccountDialog.accountTypeSelected();
+
     // The default emptySettings.xhtml is already loaded into the IFrame
     // at this point, before we could attach our DOMContentLoaded event
     // listener, so we'll call the function here manually.
     this.onIFrameLoaded(null);
 
     addAccountDialog.fitIFrame();
   },
 
@@ -155,16 +151,38 @@ var addAccountDialog = {
       this._settings.style.height = this._settings.style.minHeight = "16px";
       return;
     }
     let newHeight = this._settings.contentDocument.body.offsetHeight;
     this._settings.style.height = this._settings.style.minHeight = newHeight + "px";
     window.sizeToContent();
   },
 
+  switchIframeType(type, src) {
+    if (type == this._settings.getAttribute("type")) {
+      return;
+    }
+
+    let frame = document.createElement("iframe");
+    frame.setAttribute("class", "indent");
+    frame.setAttribute("allowfullscreen", "false");
+    frame.setAttribute("flex", "1");
+    frame.setAttribute("type", type);
+    frame.setAttribute("src", src);
+
+    // allows keeping dialog background color without hoops
+    frame.setAttribute("transparent", "true");
+
+    this._settings.parentNode.replaceChild(frame, this._settings);
+    this._settings = frame;
+
+    this._settings.addEventListener("DOMContentLoaded", this);
+    this._settings.addEventListener("overflow", this);
+  },
+
   removeTitleMenuItem() {
     let menuitem = this._accountType.querySelector('menuitem[value=""]');
     if (menuitem)
       menuitem.remove();
   },
 
   // Return number of additions to the menulist, zero if none happened.
   addAccountTypes() {
@@ -226,16 +244,22 @@ var addAccountDialog = {
     this.onUnInit();
     return false;
   },
 
   getExtraArgs() {
     if (!this._settings)
       return {};
 
+    if (this._settings.contentDocument.location.href.startsWith("moz-extension:")) {
+      // WebExtensions use their own storage mechanism, don't use extraArgs from
+      // their content document
+      return {};
+    }
+
     let func = this._settings.contentWindow
                    .wrappedJSObject
                    .extraArgs;
     if (!func)
       return {};
 
     return func();
   },
@@ -248,17 +272,18 @@ var addAccountDialog = {
     let provider = cloudFileAccounts.getProviderForType(providerKey);
     if (!provider)
       return;
 
     // Reset the message display
     this._messages.selectedIndex = -1;
 
     // Load up the correct XHTML page for this provider.
-    this._settings.contentDocument.location.href = provider.settingsURL;
+    let type = provider.settingsURL.startsWith("chrome:") ? "chrome" : "content";
+    this.switchIframeType(type, provider.settingsURL);
   },
 
   onClickLink(e) {
     e.preventDefault();
     let href = e.target.getAttribute("href");
     gProtocolService.loadURI(Services.io.newURI(href, "UTF-8"));
   },
 
--- a/mail/components/compose/content/MsgComposeCommands.js
+++ b/mail/components/compose/content/MsgComposeCommands.js
@@ -1544,46 +1544,46 @@ uploadListener.prototype = {
         { bubbles: true, cancelable: true }));
     } else {
       let title;
       let msg;
       let displayName = cloudFileAccounts.getDisplayName(this.cloudProvider);
       let bundle = getComposeBundle();
       let displayError = true;
       switch (aStatusCode) {
-      case this.cloudProvider.authErr:
+      case Ci.nsIMsgCloudFileProvider.authErr:
         title = bundle.getString("errorCloudFileAuth.title");
         msg = bundle.getFormattedString("errorCloudFileAuth.message",
                                         [displayName]);
         break;
-      case this.cloudProvider.uploadErr:
+      case Ci.nsIMsgCloudFileProvider.uploadErr:
         title = bundle.getString("errorCloudFileUpload.title");
         msg = bundle.getFormattedString("errorCloudFileUpload.message",
                                         [displayName,
                                          this.attachment.name]);
         break;
-      case this.cloudProvider.uploadWouldExceedQuota:
+      case Ci.nsIMsgCloudFileProvider.uploadWouldExceedQuota:
         title = bundle.getString("errorCloudFileQuota.title");
         msg = bundle.getFormattedString("errorCloudFileQuota.message",
                                         [displayName,
                                          this.attachment.name]);
         break;
-      case this.cloudProvider.uploadExceedsFileNameLimit:
+      case Ci.nsIMsgCloudFileProvider.uploadExceedsFileNameLimit:
         title = bundle.getString("errorCloudFileNameLimit.title");
         msg = bundle.getFormattedString("errorCloudFileNameLimit.message",
                                         [displayName,
                                          this.attachment.name]);
         break;
-      case this.cloudProvider.uploadExceedsFileLimit:
+      case Ci.nsIMsgCloudFileProvider.uploadExceedsFileLimit:
         title = bundle.getString("errorCloudFileLimit.title");
         msg = bundle.getFormattedString("errorCloudFileLimit.message",
                                         [displayName,
                                          this.attachment.name]);
         break;
-      case this.cloudProvider.uploadCanceled:
+      case Ci.nsIMsgCloudFileProvider.uploadCanceled:
         displayError = false;
         break;
       default:
         title = bundle.getString("errorCloudFileOther.title");
         msg = bundle.getFormattedString("errorCloudFileOther.message",
                                         [displayName]);
         break;
       }
--- a/mail/components/extensions/ext-mail.json
+++ b/mail/components/extensions/ext-mail.json
@@ -3,16 +3,25 @@
     "url": "chrome://messenger/content/parent/ext-browserAction.js",
     "schema": "chrome://messenger/content/schemas/browserAction.json",
     "scopes": ["addon_parent"],
     "manifest": ["browser_action"],
     "paths": [
       ["browserAction"]
     ]
   },
+  "cloudFile": {
+    "url": "chrome://messenger/content/parent/ext-cloudFile.js",
+    "schema": "chrome://messenger/content/schemas/cloudFile.json",
+    "scopes": ["addon_parent"],
+    "manifest": ["cloud_file"],
+    "paths": [
+      ["cloudFile"]
+    ]
+  },
   "composeAction": {
     "url": "chrome://messenger/content/parent/ext-composeAction.js",
     "schema": "chrome://messenger/content/schemas/composeAction.json",
     "scopes": ["addon_parent"],
     "manifest": ["compose_action"],
     "paths": [
       ["composeAction"]
     ]
--- a/mail/components/extensions/jar.mn
+++ b/mail/components/extensions/jar.mn
@@ -3,23 +3,25 @@
 # 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/parent/ext-addressBook.js    (parent/ext-addressBook.js)
     content/messenger/parent/ext-browserAction.js  (parent/ext-browserAction.js)
+    content/messenger/parent/ext-cloudFile.js      (parent/ext-cloudFile.js)
     content/messenger/parent/ext-composeAction.js  (parent/ext-composeAction.js)
     content/messenger/parent/ext-legacy.js         (parent/ext-legacy.js)
     content/messenger/parent/ext-mail.js           (parent/ext-mail.js)
     content/messenger/parent/ext-tabs.js           (parent/ext-tabs.js)
     content/messenger/parent/ext-windows.js        (parent/ext-windows.js)
 
     content/messenger/child/ext-tabs.js            (child/ext-tabs.js)
     content/messenger/child/ext-mail.js            (child/ext-mail.js)
 
     content/messenger/schemas/addressBook.json     (schemas/addressBook.json)
     content/messenger/schemas/browserAction.json   (schemas/browserAction.json)
+    content/messenger/schemas/cloudFile.json       (schemas/cloudFile.json)
     content/messenger/schemas/composeAction.json   (schemas/composeAction.json)
     content/messenger/schemas/legacy.json          (schemas/legacy.json)
     content/messenger/schemas/tabs.json            (schemas/tabs.json)
     content/messenger/schemas/windows.json         (schemas/windows.json)
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/parent/ext-cloudFile.js
@@ -0,0 +1,353 @@
+/* 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";
+
+ChromeUtils.import("resource://gre/modules/ExtensionParent.jsm");
+ChromeUtils.import("resource:///modules/cloudFileAccounts.js");
+
+Cu.importGlobalProperties(["File", "FileReader"]);
+
+async function promiseFileRead(nsifile) {
+  let blob = await File.createFromNsIFile(nsifile);
+
+  return new Promise((resolve, reject) => {
+    let reader = new FileReader();
+    reader.addEventListener("loadend", () => {
+      resolve(reader.result);
+    });
+    reader.addEventListener("onerror", reject);
+
+    reader.readAsArrayBuffer(blob);
+  });
+}
+
+class CloudFileProvider extends EventEmitter {
+  constructor(extension) {
+    super();
+
+    this.extension = extension;
+    this.configured = false;
+    this.accountKey = false;
+    this.lastError = "";
+    this.settingsURL = this.extension.manifest.cloud_file.settings_url;
+    this.managementURL = this.extension.manifest.cloud_file.management_url;
+    this.quota = {
+      uploadSizeLimit: -1,
+      spaceRemaining: -1,
+      spaceUsed: -1,
+    };
+
+    this._nextId = 1;
+    this._fileUrls = new Map();
+    this._fileIds = new Map();
+  }
+
+  get type() {
+    return `ext-${this.extension.id}`;
+  }
+  get displayName() {
+    return this.extension.manifest.cloud_file.name;
+  }
+  get serviceURL() {
+    return this.extension.manifest.cloud_file.service_url;
+  }
+  get iconClass() {
+    if (this.extension.manifest.icons) {
+      let { icon } = ExtensionParent.IconDetails.getPreferredIcon(
+        this.extension.manifest.icons, this.extension, 32
+      );
+      return this.extension.getURL(icon);
+    }
+    return "chrome://messenger/content/extension.svg";
+  }
+  get fileUploadSizeLimit() {
+    return this.quota.uploadSizeLimit;
+  }
+  get remainingFileSpace() {
+    return this.quota.spaceRemaining;
+  }
+  get fileSpaceUsed() {
+    return this.quota.spaceUsed;
+  }
+  get createNewAccountUrl() {
+    return this.extension.manifest.cloud_file.new_account_url;
+  }
+
+  init(accountKey) {
+    this.accountKey = accountKey;
+    Services.prefs.setCharPref(
+      `mail.cloud_files.accounts.${accountKey}.displayName`, this.displayName
+    );
+  }
+
+  async uploadFile(file, callback) {
+    let id = this._nextId++;
+    let results;
+
+    try {
+      let buffer = await promiseFileRead(file);
+
+      this._fileIds.set(file.path, id);
+      results = await this.emit("uploadFile", {
+        id,
+        name: file.leafName,
+        data: buffer,
+      });
+    } catch (ex) {
+      if (ex.result == 0x80530014) { // NS_ERROR_DOM_ABORT_ERR
+        callback.onStopRequest(null, null, Ci.nsIMsgCloudFileProvider.uploadCanceled);
+      } else {
+        console.error(ex);
+        callback.onStopRequest(null, null, Ci.nsIMsgCloudFileProvider.uploadErr);
+      }
+      return;
+    }
+
+    if (results && results.length > 0) {
+      if (results[0].aborted) {
+        callback.onStopRequest(null, null, Ci.nsIMsgCloudFileProvider.uploadCanceled);
+        return;
+      }
+
+      let url = results[0].url;
+      this._fileUrls.set(file.path, url);
+      callback.onStopRequest(null, null, Cr.NS_OK);
+    } else {
+      callback.onStopRequest(null, null, Ci.nsIMsgCloudFileProvider.uploadErr);
+      throw new ExtensionUtils.ExtensionError(
+        `Missing cloudFile.onFileUpload listener for ${this.extension.id}`
+      );
+    }
+  }
+
+  urlForFile(file) {
+    return this._fileUrls.get(file.path);
+  }
+
+  cancelFileUpload(file) {
+    this.emit("uploadAbort", {
+      id: this._fileIds.get(file.path),
+    });
+  }
+
+  refreshUserInfo(withUI, callback) {
+    if (Services.io.offline) {
+      throw Ci.nsIMsgCloudFileProvider.offlineErr;
+    }
+    callback.onStopRequest(null, null, Cr.NS_OK);
+  }
+
+  async deleteFile(file, callback) {
+    let results;
+    try {
+      if (this._fileIds.has(file.path)) {
+        let id = this._fileIds.get(file.path);
+        results = await this.emit("deleteFile", { id });
+      }
+    } catch (ex) {
+      callback.onStopRequest(null, null, Cr.NS_ERROR_FAILURE);
+    }
+
+    if (results && results.length > 0) {
+      callback.onStopRequest(null, null, Cr.NS_OK);
+    } else {
+      callback.onStopRequest(null, null, Cr.NS_ERROR_FAILURE);
+      throw new ExtensionUtils.ExtensionError(
+        `Missing cloudFile.onFileDeleted listener for ${this.extension.id}`
+      );
+    }
+  }
+
+  createNewAccount(...args) {
+    throw Cr.NS_ERROR_NOT_IMPLEMENTED;
+  }
+
+  createExistingAccount(callback) {
+    if (Services.io.offline) {
+      throw Ci.nsIMsgCloudFileProvider.offlineErr;
+    }
+    // We're assuming everything is ok here. Maybe expose this in the future if there is a need
+    callback.onStopRequest(null, this, Cr.NS_OK);
+  }
+
+  providerUrlForError(error) {
+    return "";
+  }
+
+  overrideUrls(count, urls) {
+  }
+
+  register() {
+    cloudFileAccounts.registerProvider(this);
+  }
+
+  unregister() {
+    cloudFileAccounts.unregisterProvider(this.type);
+  }
+}
+CloudFileProvider.prototype.QueryInterface = ChromeUtils.generateQI([Ci.nsIMsgCloudFileProvider]);
+
+function convertAccount(nativeAccount) {
+  return {
+    id: nativeAccount.accountKey,
+    name: nativeAccount.displayName,
+    configured: nativeAccount.configured,
+    uploadSizeLimit: nativeAccount.fileUploadSizeLimit,
+    spaceRemaining: nativeAccount.remainingFileSpace,
+    spaceUsed: nativeAccount.fileSpaceUsed,
+    managementUrl: nativeAccount.managementURL,
+    settingsUrl: nativeAccount.settingsURL,
+  };
+}
+
+this.cloudFile = class extends ExtensionAPI {
+  onManifestEntry(entryName) {
+    if (entryName == "cloud_file" && !this.provider) {
+      this.provider = new CloudFileProvider(this.extension);
+      this.provider.register();
+    }
+  }
+
+  onShutdown() {
+    if (this.provider) {
+      this.provider.unregister();
+    }
+  }
+
+  getAPI(context) {
+    let self = this;
+    return {
+      cloudFile: {
+        onFileUpload: new EventManager({
+          context,
+          name: "cloudFile.onFileUpload",
+          register: fire => {
+            let listener = (event, { id, name, data }) => {
+              let account = convertAccount(self.provider);
+              return fire.async(account, { id, name, data });
+            };
+
+            self.provider.on("uploadFile", listener);
+            return () => {
+              self.provider.off("uploadFile", listener);
+            };
+          },
+        }).api(),
+
+        onFileUploadAbort: new EventManager({
+          context,
+          name: "cloudFile.onFileUploadAbort",
+          register: fire => {
+            let listener = (event, { id }) => {
+              let account = convertAccount(self.provider);
+              return fire.async(account, id);
+            };
+
+            self.provider.on("uploadAbort", listener);
+            return () => {
+              self.provider.off("uploadAbort", listener);
+            };
+          },
+        }).api(),
+
+        onFileDeleted: new EventManager({
+          context,
+          name: "cloudFile.onFileDeleted",
+          register: fire => {
+            let listener = (event, { id }) => {
+              let account = convertAccount(self.provider);
+              return fire.async(account, id);
+            };
+
+            self.provider.on("deleteFile", listener);
+            return () => {
+              self.provider.off("deleteFile", listener);
+            };
+          },
+        }).api(),
+
+        onAccountAdded: new EventManager({
+          context,
+          name: "cloudFile.onAccountAdded",
+          register: fire => {
+            let listener = (event, nativeAccount) => {
+              if (nativeAccount.type != this.provider.type) {
+                return null;
+              }
+
+              return fire.async(convertAccount(nativeAccount));
+            };
+
+            cloudFileAccounts.on("accountAdded", listener);
+            return () => {
+              cloudFileAccounts.off("accountAdded", listener);
+            };
+          },
+        }).api(),
+
+        onAccountDeleted: new EventManager({
+          context,
+          name: "cloudFile.onAccountDeleted",
+          register: fire => {
+            let listener = (event, key, type) => {
+              if (this.provider.type != type) {
+                return null;
+              }
+
+              return fire.async(key);
+            };
+
+            cloudFileAccounts.on("accountDeleted", listener);
+            return () => {
+              cloudFileAccounts.off("accountDeleted", listener);
+            };
+          },
+        }).api(),
+
+        async getAccount(accountId) {
+          let account = cloudFileAccounts.getAccount(accountId);
+
+          if (!account || account.type != self.provider.type) {
+            return undefined;
+          }
+
+          return convertAccount(account);
+        },
+
+        async getAllAccounts() {
+          return cloudFileAccounts.getAccountsForType(self.provider.type).map(convertAccount);
+        },
+
+        async updateAccount(accountId, updateProperties) {
+          let account = cloudFileAccounts.getAccount(accountId);
+
+          if (!account || account.type != self.provider.type) {
+            return undefined;
+          }
+          if (updateProperties.configured !== null) {
+            account.configured = updateProperties.configured;
+          }
+          if (updateProperties.uploadSizeLimit !== null) {
+            account.quota.uploadSizeLimit = updateProperties.uploadSizeLimit;
+          }
+          if (updateProperties.spaceRemaining !== null) {
+            account.quota.spaceRemaining = updateProperties.spaceRemaining;
+          }
+          if (updateProperties.spaceUsed !== null) {
+            account.quota.spaceUsed = updateProperties.spaceUsed;
+          }
+          if (updateProperties.managementUrl !== null) {
+            account.managementURL = updateProperties.managementUrl;
+          }
+          if (updateProperties.settingsUrl !== null) {
+            account.settingsURL = updateProperties.settingsUrl;
+          }
+
+          return convertAccount(account);
+        },
+      },
+    };
+  }
+};
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/schemas/cloudFile.json
@@ -0,0 +1,273 @@
+[
+  {
+    "namespace": "manifest",
+    "types": [
+      {
+        "$extend": "WebExtensionManifest",
+        "properties": {
+          "cloud_file": {
+            "type": "object",
+            "additionalProperties": {
+              "$ref": "UnrecognizedProperty"
+            },
+            "properties": {
+              "name": {
+                "type": "string",
+                "preprocess": "localize"
+              },
+              "service_url": {
+                "type": "string",
+                "optional": true
+              },
+              "new_account_url": {
+                "type": "string",
+                "optional": true
+              },
+              "settings_url": {
+                "type": "string",
+                "format": "relativeUrl",
+                "preprocess": "localize"
+              },
+              "management_url": {
+                "type": "string",
+                "format": "relativeUrl",
+                "preprocess": "localize"
+              }
+            },
+            "optional": true
+          }
+        }
+      }
+    ]
+  },
+  {
+    "namespace": "cloudFile",
+    "events": [
+      {
+        "name": "onFileUpload",
+        "type": "function",
+        "description": "Fired when a file should be uploaded to the cloud file provider",
+        "parameters": [
+          {
+            "name": "account",
+            "$ref": "CloudFileAccount",
+            "description": "The created account"
+          },
+          {
+            "name": "fileInfo",
+            "$ref": "CloudFile",
+            "description": "The file to upload"
+          }
+        ],
+        "returns": {
+          "type": "object",
+          "properties": {
+            "aborted": {
+              "type": "boolean",
+              "description": "Set this to true if the file upload was aborted",
+              "optional": true
+            },
+            "url": {
+              "type": "string",
+              "description": "The URL where the uploaded file can be accessed",
+              "optional": true
+            }
+          }
+        }
+      },
+      {
+        "name": "onFileUploadAbort",
+        "type": "function",
+        "parameters": [
+          {
+            "name": "account",
+            "$ref": "CloudFileAccount",
+            "description": "The created account"
+          },
+          {
+            "type": "integer",
+            "name": "fileId",
+            "minimum": 1
+          }
+        ]
+      },
+      {
+        "name": "onFileDeleted",
+        "type": "function",
+        "description": "Fired when a file previously uploaded should be deleted",
+        "parameters": [
+          {
+            "name": "account",
+            "$ref": "CloudFileAccount",
+            "description": "The created account"
+          },
+          {
+            "type": "integer",
+            "name": "fileId",
+            "minimum": 1,
+            "description": "An identifier for this file, TODO might go away"
+          }
+        ]
+      },
+      {
+        "name": "onAccountAdded",
+        "type": "function",
+        "description": "Fired when a cloud file account of this add-on was created",
+        "parameters": [
+          {
+            "name": "account",
+            "$ref": "CloudFileAccount",
+            "description": "The created account"
+          }
+        ]
+      },
+      {
+        "name": "onAccountDeleted",
+        "type": "function",
+        "description": "Fired when a cloud file account of this add-on was deleted",
+        "parameters": [
+          {
+            "name": "accountId",
+            "type": "string",
+            "description": "The id of the removed account"
+          }
+        ]
+      }
+    ],
+    "types": [
+      {
+        "id": "CloudFileAccount",
+        "type": "object",
+        "description": "Information about a cloud file account",
+        "properties": {
+          "id": {
+            "type": "string"
+          },
+          "configured": {
+            "type": "boolean"
+          },
+          "name": {
+            "type": "string"
+          },
+          "uploadSizeLimit": {
+            "type": "integer",
+            "minimum": -1,
+            "optional": true,
+            "description": "The maximum size in bytes for a single file to upload. Set to -1 if unlimited."
+          },
+          "spaceRemaining": {
+            "type": "integer",
+            "minimum": -1,
+            "optional": true,
+            "description": "The amount of remaining space on the cloud provider, in bytes. Set to -1 if unsupported."
+          },
+          "spaceUsed": {
+            "type": "integer",
+            "minimum": -1,
+            "optional": true,
+            "description": "The amount of space already used on the cloud provider, in bytes. Set to -1 if unsupported."
+          },
+          "managementUrl": {
+            "type": "string",
+            "format": "relativeUrl"
+          },
+          "settingsUrl": {
+            "type": "string",
+            "format": "relativeUrl"
+          }
+        }
+      },
+      {
+        "id": "CloudFile",
+        "type": "object",
+        "description": "Information about a cloud file",
+        "properties": {
+          "id": {
+            "type": "integer",
+            "minimum": 1
+          },
+          "name": {
+            "type": "string"
+          },
+          "data": {
+            "type": "object",
+            "isInstanceOf": "ArrayBuffer",
+            "additionalProperties": true
+          }
+        }
+      }
+    ],
+    "functions": [
+      {
+        "name": "getAccount",
+        "type": "function",
+        "description": "Retrieve information about a single cloud file account",
+        "async": true,
+        "parameters": [
+          {
+            "name": "accountId",
+            "type": "string"
+          }
+        ]
+      },
+      {
+        "name": "getAllAccounts",
+        "type": "function",
+        "description": "Retrieve all cloud file accounts for the current add-on",
+        "parameters": [],
+        "async": true
+      },
+      {
+        "name": "updateAccount",
+        "type": "function",
+        "description": "Update a cloud file account",
+        "parameters": [
+          {
+            "name": "accountId",
+            "type": "string"
+          },
+          {
+            "name": "updateProperties",
+            "type": "object",
+            "properties": {
+              "configured": {
+                "type": "boolean",
+                "optional": true,
+                "description": "If true, the account is configured and ready to use."
+              },
+              "uploadSizeLimit": {
+                "type": "integer",
+                "minimum": -1,
+                "optional": true,
+                "description": "The maximum size in bytes for a single file to upload. Set to -1 if unlimited."
+              },
+              "spaceRemaining": {
+                "type": "integer",
+                "minimum": -1,
+                "optional": true,
+                "description": "The amount of remaining space on the cloud provider, in bytes. Set to -1 if unsupported."
+              },
+              "spaceUsed": {
+                "type": "integer",
+                "minimum": -1,
+                "optional": true,
+                "description": "The amount of space already used on the cloud provider, in bytes. Set to -1 if unsupported."
+              },
+              "managementUrl": {
+                "type": "string",
+                "format": "relativeUrl",
+                "optional": true
+              },
+              "settingsUrl": {
+                "type": "string",
+                "format": "relativeUrl",
+                "optional": true
+              }
+            }
+          }
+        ],
+        "async": true
+      }
+    ]
+  }
+]
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/test/xpcshell/data/cloudFile1.txt
@@ -0,0 +1,1 @@
+you got the moves!
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/test/xpcshell/data/cloudFile2.txt
@@ -0,0 +1,1 @@
+you got the moves!
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/test/xpcshell/test_ext_cloudFile.js
@@ -0,0 +1,263 @@
+/* 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";
+
+ChromeUtils.import("resource:///modules/cloudFileAccounts.js");
+ChromeUtils.import("resource://testing-common/ExtensionXPCShellUtils.jsm");
+
+ExtensionTestUtils.init(this);
+
+add_task(async () => {
+  async function background() {
+    function createCloudfileAccount() {
+      return new Promise((resolve) => {
+        function accountListener(account) {
+          browser.cloudFile.onAccountAdded.removeListener(accountListener);
+          resolve(account);
+        }
+
+        browser.cloudFile.onAccountAdded.addListener(accountListener);
+        browser.test.sendMessage("createAccount");
+      });
+    }
+
+    function removeCloudfileAccount(id) {
+      return new Promise((resolve) => {
+        function accountListener(accountId) {
+          browser.cloudFile.onAccountDeleted.removeListener(accountListener);
+          resolve(accountId);
+        }
+
+        browser.cloudFile.onAccountDeleted.addListener(accountListener);
+        browser.test.sendMessage("removeAccount", id);
+      });
+    }
+
+    function assertAccountsMatch(b, a) {
+      browser.test.assertEq(a.id, b.id);
+      browser.test.assertEq(a.name, b.name);
+      browser.test.assertEq(a.configured, b.configured);
+      browser.test.assertEq(a.uploadSizeLimit, b.uploadSizeLimit);
+      browser.test.assertEq(a.spaceRemaining, b.spaceRemaining);
+      browser.test.assertEq(a.spaceUsed, b.spaceUsed);
+      browser.test.assertEq(a.managementUrl, b.managementUrl);
+      browser.test.assertEq(a.settingsUrl, b.settingsUrl);
+    }
+
+    async function test_account_creation_removal() {
+      browser.test.log("test_account_creation_removal");
+      // Account creation
+      let createdAccount = await createCloudfileAccount();
+      assertAccountsMatch(createdAccount, {
+        id: "account1",
+        name: "xpcshell",
+        configured: false,
+        uploadSizeLimit: -1,
+        spaceRemaining: -1,
+        spaceUsed: -1,
+        managementUrl: browser.runtime.getURL("/content/management.html"),
+        settingsUrl: browser.runtime.getURL("/content/settings.html"),
+      });
+
+      // Other account creation
+      await new Promise((resolve, reject) => {
+        function accountListener(account) {
+          browser.cloudFile.onAccountAdded.removeListener(accountListener);
+          browser.test.fail("Got onAccountAdded for account from other addon");
+          reject();
+        }
+
+        browser.cloudFile.onAccountAdded.addListener(accountListener);
+        browser.test.sendMessage("createAccount", "ext-other-addon");
+
+        // Resolve in the next tick
+        setTimeout(() => {
+          browser.cloudFile.onAccountAdded.removeListener(accountListener);
+          resolve();
+        }, 0);
+      });
+
+      // Account removal
+      let removedAccountId = await removeCloudfileAccount(createdAccount.id);
+      browser.test.assertEq(createdAccount.id, removedAccountId);
+    }
+
+    async function test_getters_update() {
+      browser.test.log("test_getters_update");
+      browser.test.sendMessage("createAccount", "ext-other-addon");
+
+      let createdAccount = await createCloudfileAccount();
+
+      // getAccount and getAllAccounts
+      let retrievedAccount = await browser.cloudFile.getAccount(createdAccount.id);
+      assertAccountsMatch(createdAccount, retrievedAccount);
+
+      let retrievedAccounts = await browser.cloudFile.getAllAccounts();
+      browser.test.assertEq(retrievedAccounts.length, 1);
+      assertAccountsMatch(createdAccount, retrievedAccounts[0]);
+
+      // update()
+      let changes = {
+        configured: true,
+        // uploadSizeLimit intentionally left unset
+        spaceRemaining: 456,
+        spaceUsed: 789,
+        managementUrl: "/account.html",
+        settingsUrl: "/accountsettings.html",
+      };
+
+      let changedAccount = await browser.cloudFile.updateAccount(retrievedAccount.id, changes);
+      retrievedAccount = await browser.cloudFile.getAccount(createdAccount.id);
+
+      let expected = {
+        id: createdAccount.id,
+        name: "xpcshell",
+        configured: true,
+        uploadSizeLimit: -1,
+        spaceRemaining: 456,
+        spaceUsed: 789,
+        managementUrl: browser.runtime.getURL("/account.html"),
+        settingsUrl: browser.runtime.getURL("/accountsettings.html"),
+      };
+
+      assertAccountsMatch(changedAccount, expected);
+      assertAccountsMatch(retrievedAccount, expected);
+
+      await removeCloudfileAccount(createdAccount.id);
+    }
+
+    async function test_upload_delete() {
+      browser.test.log("test_upload_delete");
+      let createdAccount = await createCloudfileAccount();
+
+      let fileId = await new Promise((resolve) => {
+        function fileListener(account, { id, name, data }) {
+          browser.cloudFile.onFileUpload.removeListener(fileListener);
+          browser.test.assertEq(account.id, createdAccount.id);
+          browser.test.assertEq(name, "cloudFile1.txt");
+          browser.test.assertEq(new TextDecoder("utf-8").decode(data), "you got the moves!\n");
+          setTimeout(() => resolve(id));
+          return { url: "https://example.com/" + name };
+        }
+
+        browser.cloudFile.onFileUpload.addListener(fileListener);
+        browser.test.sendMessage("uploadFile", createdAccount.id, "cloudFile1");
+      });
+
+      browser.test.log("test upload aborted");
+      await new Promise((resolve) => {
+        async function fileListener(account, { id, name, data }) {
+          browser.cloudFile.onFileUpload.removeListener(fileListener);
+
+          // The listener won't return until onFileUploadAbort fires. When that happens,
+          // we return an aborted message, which completes the abort cycle.
+          await new Promise((resolveAbort) => {
+            function abortListener(accountAccount, abortId) {
+              browser.cloudFile.onFileUploadAbort.removeListener(abortListener);
+              resolveAbort();
+            }
+            browser.cloudFile.onFileUploadAbort.addListener(abortListener);
+            browser.test.sendMessage("cancelUpload", createdAccount.id);
+          });
+
+          setTimeout(resolve);
+          return { aborted: true };
+        }
+
+        browser.cloudFile.onFileUpload.addListener(fileListener);
+        browser.test.sendMessage("uploadFile", createdAccount.id, "cloudFile2", "uploadCanceled");
+      });
+
+      browser.test.log("test delete");
+      await new Promise((resolve) => {
+        function fileListener(account, id) {
+          browser.cloudFile.onFileDeleted.removeListener(fileListener);
+          browser.test.assertEq(account.id, createdAccount.id);
+          browser.test.assertEq(id, fileId);
+          setTimeout(resolve);
+        }
+
+        browser.cloudFile.onFileDeleted.addListener(fileListener);
+        browser.test.sendMessage("deleteFile", createdAccount.id);
+      });
+
+      await removeCloudfileAccount(createdAccount.id);
+      await new Promise(setTimeout);
+    }
+
+    // Tests to run
+    await test_account_creation_removal();
+    await test_getters_update();
+    await test_upload_delete();
+
+    browser.test.notifyPass("cloudFile");
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background,
+    manifest: {
+      cloud_file: {
+        name: "xpcshell",
+        settings_url: "/content/settings.html",
+        management_url: "/content/management.html",
+      },
+      applications: { gecko: { id: "cloudfile@xpcshell" } },
+    },
+  });
+
+  let testFiles = {
+    "cloudFile1": do_get_file("data/cloudFile1.txt"),
+    "cloudFile2": do_get_file("data/cloudFile2.txt"),
+  };
+
+  extension.onMessage("createAccount", (id = "ext-cloudfile@xpcshell") => {
+    cloudFileAccounts.createAccount(id, {
+      onStartRequest() {},
+      onStopRequest() {},
+    }, null);
+  });
+
+  extension.onMessage("removeAccount", (id) => {
+    cloudFileAccounts.removeAccount(id);
+  });
+
+  extension.onMessage("uploadFile", (accountId, filename, expected = Cr.NS_OK) => {
+    let account = cloudFileAccounts.getAccount(accountId);
+
+    if (typeof expected == "string") {
+      expected = Ci.nsIMsgCloudFileProvider[expected];
+    }
+
+    account.uploadFile(testFiles[filename], {
+      onStartRequest() {},
+      onStopRequest(req, context, status) {
+        Assert.equal(status, expected);
+      },
+    });
+  });
+
+  extension.onMessage("cancelUpload", (id) => {
+    let account = cloudFileAccounts.getAccount(id);
+    account.cancelFileUpload(testFiles.cloudFile2);
+  });
+
+  extension.onMessage("deleteFile", (id) => {
+    let account = cloudFileAccounts.getAccount(id);
+
+    account.deleteFile(testFiles.cloudFile1, {
+      onStartRequest() {},
+      onStopRequest() {},
+    });
+  });
+
+  Assert.ok(!cloudFileAccounts.getProviderForType("ext-cloudfile@xpcshell"));
+  await extension.startup();
+  Assert.ok(cloudFileAccounts.getProviderForType("ext-cloudfile@xpcshell"));
+
+  await extension.awaitFinish("cloudFile");
+  await extension.unload();
+
+  Assert.ok(!cloudFileAccounts.getProviderForType("ext-cloudfile@xpcshell"));
+});
--- a/mail/components/extensions/test/xpcshell/xpcshell.ini
+++ b/mail/components/extensions/test/xpcshell/xpcshell.ini
@@ -1,5 +1,7 @@
 [default]
 head = head.js
 tags = webextensions
 
 [test_ext_addressBook.js]
+[test_ext_cloudFile.js]
+support-files = data/cloudFile1.txt data/cloudFile2.txt
--- a/mail/components/preferences/applications.js
+++ b/mail/components/preferences/applications.js
@@ -645,16 +645,22 @@ var gCloudFileTab = {
   },
 
   _showAccountManagement(aProvider) {
     let iframe = document.createElement("iframe");
 
     iframe.setAttribute("src", aProvider.managementURL);
     iframe.setAttribute("flex", "1");
 
+    let type = aProvider.settingsURL.startsWith("chrome:") ? "chrome" : "content";
+    iframe.setAttribute("type", type);
+
+    // allows keeping dialog background color without hoops
+    iframe.setAttribute("transparent", "true");
+
     // If we have a past iframe, we replace it. Else append
     // to the wrapper.
     if (this._settings)
       this._settings.remove();
 
     this._settingsPanelWrap.appendChild(iframe);
     this._settings = iframe;
 
--- a/mail/themes/linux/mail/cloudfile/addAccountDialog.css
+++ b/mail/themes/linux/mail/cloudfile/addAccountDialog.css
@@ -86,15 +86,19 @@ a {
 
 /* Make the icons show up in the menulist */
 #accountType > menupopup > menuitem > .menu-iconic-left {
   padding-inline-end: 2px;
   display: -moz-box;
   min-width: 16px;
 }
 
+.menulist-icon {
+  width: 16px;
+}
+
 #learn-more {
   text-align: right;
 }
 
 .float-right {
   float: right;
 }
--- a/mail/themes/osx/mail/cloudfile/addAccountDialog.css
+++ b/mail/themes/osx/mail/cloudfile/addAccountDialog.css
@@ -91,15 +91,19 @@ a {
 }
 
 #accountType > menupopup > menuitem > .menu-iconic-left {
   display: -moz-box;
   min-width: 16px;
   padding-inline-end: 2px;
 }
 
+.menulist-icon {
+  width: 16px;
+}
+
 #learn-more {
   text-align: right;
 }
 
 .float-right {
   float: right;
 }
--- a/mail/themes/windows/mail/cloudfile/addAccountDialog.css
+++ b/mail/themes/windows/mail/cloudfile/addAccountDialog.css
@@ -86,16 +86,20 @@ a {
 }
 
 #accountType > menupopup > menuitem > .menu-iconic-left {
   display: -moz-box;
   min-width: 16px;
   padding-inline-end: 2px;
 }
 
+.menulist-icon {
+  width: 16px;
+}
+
 #learn-more {
   text-align: right;
 }
 
 .float-right {
   float: right;
 }