Bug 1469238 - Address Book WebExtensions API; r=mkmelin
☠☠ backed out by aa44e834870f ☠ ☠
authorGeoff Lankow <geoff@darktrojan.net>
Thu, 27 Sep 2018 12:01:32 +1200
changeset 33241 4dbfa038c9362bbda2010c883f15688f50a9626e
parent 33240 60d7adb0d3bd2503f9de5ffe6627c713d27b54e6
child 33242 aa44e834870fc2310c0e3b19811e4f3c70bff036
push id387
push userclokep@gmail.com
push dateMon, 10 Dec 2018 21:30:47 +0000
reviewersmkmelin
bugs1469238
Bug 1469238 - Address Book WebExtensions API; r=mkmelin
mail/components/extensions/.eslintrc.js
mail/components/extensions/ext-mail.json
mail/components/extensions/jar.mn
mail/components/extensions/moz.build
mail/components/extensions/parent/.eslintrc.js
mail/components/extensions/parent/ext-addressBook.js
mail/components/extensions/schemas/addressBook.json
mail/components/extensions/test/xpcshell/.eslintrc.js
mail/components/extensions/test/xpcshell/test_ext_addressBook.js
mail/components/extensions/test/xpcshell/xpcshell.ini
--- a/mail/components/extensions/.eslintrc.js
+++ b/mail/components/extensions/.eslintrc.js
@@ -1,11 +1,12 @@
 "use strict";
+/* eslint-env node */
 
-module.exports = { // eslint-disable-line no-undef
+module.exports = {
   "globals": {
     // These are defined in the WebExtension script scopes by ExtensionCommon.jsm.
     // From toolkit/components/extensions/.eslintrc.js.
     "Cc": true,
     "Ci": true,
     "Cr": true,
     "Cu": true,
     "AppConstants": true,
--- a/mail/components/extensions/ext-mail.json
+++ b/mail/components/extensions/ext-mail.json
@@ -1,9 +1,17 @@
 {
+  "addressBook": {
+    "url": "chrome://messenger/content/parent/ext-addressBook.js",
+    "schema": "chrome://messenger/content/schemas/addressBook.json",
+    "scopes": ["addon_parent"],
+    "paths": [
+      ["addressBooks"], ["contacts"], ["mailingLists"]
+    ]
+  },
   "legacy": {
     "url": "chrome://messenger/content/parent/ext-legacy.js",
     "schema": "chrome://messenger/content/schemas/legacy.json",
     "scopes": ["addon_parent"],
     "manifest": ["legacy"]
   },
   "tabs": {
     "url": "chrome://messenger/content/parent/ext-tabs.js",
--- a/mail/components/extensions/jar.mn
+++ b/mail/components/extensions/jar.mn
@@ -1,18 +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/.
 
 messenger.jar:
     content/messenger/ext-mail.json                (ext-mail.json)
 
+    content/messenger/parent/ext-addressBook.js    (parent/ext-addressBook.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/legacy.json   (schemas/legacy.json)
-    content/messenger/schemas/tabs.json     (schemas/tabs.json)
-    content/messenger/schemas/windows.json  (schemas/windows.json)
+    content/messenger/schemas/addressBook.json     (schemas/addressBook.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)
--- a/mail/components/extensions/moz.build
+++ b/mail/components/extensions/moz.build
@@ -2,8 +2,12 @@
 # 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/.
 
 EXTRA_COMPONENTS += [
     'extensions-mail.manifest',
 ]
 
 JAR_MANIFESTS += ['jar.mn']
+
+XPCSHELL_TESTS_MANIFESTS += [
+    'test/xpcshell/xpcshell.ini',
+]
--- a/mail/components/extensions/parent/.eslintrc.js
+++ b/mail/components/extensions/parent/.eslintrc.js
@@ -1,11 +1,12 @@
 "use strict";
+/* eslint-env node */
 
-module.exports = { // eslint-disable-line no-undef
+module.exports = {
   "globals": {
     // From toolkit/components/extensions/parent/.eslintrc.js.
     "CONTAINER_STORE": true,
     "DEFAULT_STORE": true,
     "EventEmitter": true,
     "EventManager": true,
     "InputEventManager": true,
     "PRIVATE_STORE": true,
@@ -19,18 +20,19 @@ module.exports = { // eslint-disable-lin
     "getCookieStoreIdForContainer": true,
     "getCookieStoreIdForTab": true,
     "isContainerCookieStoreId": true,
     "isDefaultCookieStoreId": true,
     "isPrivateCookieStoreId": true,
     "isValidCookieStoreId": true,
 
     // These are defined in ext-mail.js.
-    "tabGetSender": true,
+    "ExtensionError": true,
+    "Tab": true,
+    "Window": true,
+    "WindowEventManager": true,
+    "getTabBrowser": true,
     "makeWidgetId": true,
-    "getTabBrowser": true,
-    "WindowEventManager": true,
+    "tabGetSender": true,
     "tabTracker": true,
     "windowTracker": true,
-    "Tab": true,
-    "Window": true,
   },
 };
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/parent/ext-addressBook.js
@@ -0,0 +1,551 @@
+/* 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/. */
+
+const uuidGenerator = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
+
+ChromeUtils.import("resource://gre/modules/Services.jsm");
+ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+const AB_WINDOW_TYPE = "mail:addressbook";
+const AB_WINDOW_URI = "chrome://messenger/content/addressbook/addressbook.xul";
+
+const kPABDirectory = 2; // defined in nsDirPrefs.h
+
+// nsIAbCard.idl contains a list of properties that Thunderbird uses. Extensions are not
+// restricted to using only these properties, but the following properties cannot
+// be modified by an extension.
+const hiddenProperties = [
+  "DbRowID", "LowercasePrimaryEmail", "LastModifiedDate",
+  "PopularityIndex", "RecordKey", "UID",
+];
+
+/**
+ * Cache of items in the address book "tree". This cache is
+ * completely blown away by most changes, so operations should
+ * be as lightweight as possible.
+ *
+ * @implements {nsIAbListener}
+ * @implements {nsIObserver}
+ */
+ var cache = new class extends EventEmitter {
+  _makeContactNode(contact, parent) {
+    contact.QueryInterface(Ci.nsIAbCard);
+    return {
+      id: contact.UID,
+      parentId: parent.UID,
+      type: "contact",
+      item: contact,
+    };
+  }
+  _makeDirectoryNode(directory, parent = null) {
+    directory.QueryInterface(Ci.nsIAbDirectory);
+    let node = {
+      id: directory.UID,
+      type: directory.isMailList ? "mailingList" : "addressBook",
+      item: directory,
+      get contacts() {
+        delete this.contacts;
+        if (directory.isMailList) {
+          this.contacts = [...directory.addressLists.enumerate()];
+        } else {
+          this.contacts = [...directory.childCards]
+            .filter(c => !c.isMailList);
+        }
+        this.contacts = this.contacts.map(c => cache._makeContactNode(c, directory));
+        return this.contacts;
+      },
+      get mailingLists() {
+        delete this.mailingLists;
+        if (directory.isMailList) {
+          return undefined;
+        }
+        this.mailingLists = [];
+        for (let al of directory.addressLists.enumerate()) {
+          this.mailingLists.push(cache._makeDirectoryNode(al, directory));
+        }
+        return this.mailingLists;
+      },
+    };
+    if (parent) {
+      node.parentId = parent.UID;
+    }
+    return node;
+  }
+  _rebuild() {
+    this._tree = [];
+    for (let tld of MailServices.ab.directories) {
+      if (!tld.readOnly) {
+        this._tree.push(this._makeDirectoryNode(tld));
+      }
+    }
+  }
+  get tree() {
+    if (!this._tree) {
+      this._rebuild();
+    }
+    return this._tree;
+  }
+  flush() {
+    this._tree = null;
+  }
+  _findObjectById(type, id) {
+    function checkNode(parentNode) {
+      if (type == parentNode.type && id == parentNode.id) {
+        return parentNode;
+      }
+      if (type == "contact") {
+        return parentNode.contacts.find(c => id == c.id);
+      }
+      return null;
+    }
+
+    for (let node of this.tree) {
+      let returnNode = checkNode(node);
+      if (returnNode) {
+        return returnNode;
+      }
+
+      if (type == "addressBook" || !node.mailingLists) {
+        continue;
+      }
+
+      for (let listNode of node.mailingLists) {
+        returnNode = checkNode(listNode);
+        if (returnNode) {
+          return returnNode;
+        }
+      }
+    }
+
+    throw new ExtensionError(`${type} with id=${id} could not be found.`);
+  }
+  findAddressBookById(id) {
+    return this._findObjectById("addressBook", id);
+  }
+  findContactById(id) {
+    return this._findObjectById("contact", id);
+  }
+  findMailingListById(id) {
+    return this._findObjectById("mailingList", id);
+  }
+  convert(node, complete) {
+    if (node === null) {
+      return node;
+    }
+    if (Array.isArray(node)) {
+      return node.map(i => this.convert(i, complete));
+    }
+
+    let copy = {};
+    for (let key of ["id", "parentId", "type"]) {
+      if (key in node) {
+        copy[key] = node[key];
+      }
+    }
+
+    if (complete) {
+      for (let key of ["contacts", "mailingLists"]) {
+        if (key in node && node[key]) {
+          copy[key] = this.convert(node[key], complete);
+        }
+      }
+    }
+
+    switch (node.type) {
+      case "addressBook":
+        copy.name = node.item.dirName;
+        copy.readOnly = node.item.readOnly;
+        break;
+      case "contact": {
+        copy.properties = {};
+        for (let property of node.item.properties) {
+          if (!hiddenProperties.includes(property.name)) {
+            // WebExtensions complains if we use numbers.
+            copy.properties[property.name] = "" + property.value;
+          }
+        }
+        break;
+      }
+      case "mailingList":
+        copy.name = node.item.dirName;
+        copy.nickName = node.item.listNickName;
+        copy.description = node.item.description;
+        break;
+    }
+
+    return copy;
+  }
+
+  // nsIAbListener
+  onItemAdded(parent, item) {
+    parent.QueryInterface(Ci.nsIAbDirectory);
+
+    if (item instanceof Ci.nsIAbDirectory) {
+      item.QueryInterface(Ci.nsIAbDirectory);
+      if (item.isMailList) {
+        this.emit("mailing-list-created", this._makeDirectoryNode(item, parent));
+      } else {
+        this.emit("address-book-created", this._makeDirectoryNode(item));
+      }
+    } else if (item instanceof Ci.nsIAbCard) {
+      item.QueryInterface(Ci.nsIAbCard);
+      if (!item.isMailList && parent.isMailList) {
+        this.emit("mailing-list-member-added", this._makeContactNode(item, parent));
+      }
+    }
+
+    this._tree = null;
+  }
+  // nsIAbListener
+  onItemRemoved(parent, item) {
+    parent = parent.QueryInterface(Ci.nsIAbDirectory);
+
+    if (item instanceof Ci.nsIAbDirectory) {
+      item.QueryInterface(Ci.nsIAbDirectory);
+      if (item.isMailList) {
+        this.emit("mailing-list-deleted", parent, item);
+      } else {
+        this.emit("address-book-deleted", item);
+      }
+    } else if (item instanceof Ci.nsIAbCard) {
+      item.QueryInterface(Ci.nsIAbCard);
+      if (!item.isMailList) {
+        this.emit(parent.isMailList ? "mailing-list-member-removed" : "contact-deleted", parent, item);
+      }
+    }
+
+    this._tree = null;
+  }
+  // nsIAbListener
+  onItemPropertyChanged(item, property, oldValue, newValue) {
+    if (item instanceof Ci.nsIAbDirectory) {
+      item.QueryInterface(Ci.nsIAbDirectory);
+      if (!item.isMailList) {
+        this.emit("address-book-updated", this._makeDirectoryNode(item));
+        this._tree = null;
+      }
+    }
+  }
+
+  // nsIObserver
+  observe(subject, topic, data) {
+    this._tree = null;
+
+    switch (topic) {
+      case "addrbook-contact-created": {
+        let parentNode = this.findAddressBookById(data);
+        this.emit("contact-created", this._makeContactNode(subject, parentNode.item));
+        break;
+      }
+      case "addrbook-contact-updated": {
+        let parentNode = this.findAddressBookById(data);
+        this.emit("contact-updated", this._makeContactNode(subject, parentNode.item));
+        break;
+      }
+      case "addrbook-list-updated": {
+        subject.QueryInterface(Ci.nsIAbDirectory);
+        this.emit("mailing-list-updated", this.findMailingListById(subject.UID));
+        break;
+      }
+      case "addrbook-list-member-added": {
+        let parentNode = this.findMailingListById(data);
+        this.emit("mailing-list-member-added", this._makeContactNode(subject, parentNode.item));
+        break;
+      }
+    }
+  }
+};
+MailServices.ab.addAddressBookListener(cache, Ci.nsIAbListener.all);
+Services.obs.addObserver(cache, "addrbook-contact-created");
+Services.obs.addObserver(cache, "addrbook-contact-updated");
+Services.obs.addObserver(cache, "addrbook-list-updated");
+Services.obs.addObserver(cache, "addrbook-list-member-added");
+
+this.addressBook = class extends ExtensionAPI {
+  getAPI(context) {
+    return {
+      addressBooks: {
+        openUI() {
+          let topWindow = Services.wm.getMostRecentWindow(AB_WINDOW_TYPE);
+          if (!topWindow) {
+            // TODO: wait until window is loaded before resolving
+            topWindow = Services.ww.openWindow(null, AB_WINDOW_URI, "_blank", "chrome,extrachrome,menubar,resizable,scrollbars,status,toolbar", null);
+          }
+          topWindow.focus();
+        },
+        closeUI() {
+          for (let win of Services.wm.getEnumerator(AB_WINDOW_TYPE)) {
+            win.close();
+          }
+        },
+
+        list(complete = false) {
+          return cache.convert(cache.tree, complete);
+        },
+        get(id, complete = false) {
+          return cache.convert(cache.findAddressBookById(id), complete);
+        },
+        create({ name }) {
+          let dirName = MailServices.ab.newAddressBook(name, "", kPABDirectory);
+          let directory = MailServices.ab.getDirectoryFromId(dirName);
+          return directory.UID;
+        },
+        update(id, { name }) {
+          let node = cache.findAddressBookById(id);
+          node.item.dirName = name;
+        },
+        delete(id) {
+          let node = cache.findAddressBookById(id);
+          MailServices.ab.deleteAddressBook(node.item.URI);
+        },
+
+        onCreated: new EventManager({
+          context,
+          name: "addressBooks.onCreated",
+          register: fire => {
+            let listener = (event, node) => {
+              fire.sync(cache.convert(node));
+            };
+
+            cache.on("address-book-created", listener);
+            return () => {
+              cache.off("address-book-created", listener);
+            };
+          },
+        }).api(),
+        onUpdated: new EventManager({
+          context,
+          name: "addressBooks.onUpdated",
+          register: fire => {
+            let listener = (event, node) => {
+              fire.sync(cache.convert(node));
+            };
+
+            cache.on("address-book-updated", listener);
+            return () => {
+              cache.off("address-book-updated", listener);
+            };
+          },
+        }).api(),
+        onDeleted: new EventManager({
+          context,
+          name: "addressBooks.onDeleted",
+          register: fire => {
+            let listener = (event, item) => {
+              fire.sync(item.UID);
+            };
+
+            cache.on("address-book-deleted", listener);
+            return () => {
+              cache.off("address-book-deleted", listener);
+            };
+          },
+        }).api(),
+      },
+      contacts: {
+        list(parentId) {
+          let parentNode = cache.findAddressBookById(parentId);
+          return cache.convert(parentNode.contacts, false);
+        },
+        get(id) {
+          return cache.convert(cache.findContactById(id), false);
+        },
+        create(parentId, properties) {
+          let card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(Ci.nsIAbCard);
+          for (let [name, value] of Object.entries(properties)) {
+            if (!hiddenProperties.includes(name)) {
+              card.setProperty(name, value);
+            }
+          }
+          let parentNode = cache.findAddressBookById(parentId);
+          let newCard = parentNode.item.addCard(card);
+          return newCard.UID;
+        },
+        update(id, properties) {
+          let node = cache.findContactById(id);
+          let parentNode = cache.findAddressBookById(node.parentId);
+
+          for (let [name, value] of Object.entries(properties)) {
+            if (!hiddenProperties.includes(name)) {
+              node.item.setProperty(name, value);
+            }
+          }
+          parentNode.item.modifyCard(node.item);
+        },
+        delete(id) {
+          let node = cache.findContactById(id);
+          let parentNode = cache.findAddressBookById(node.parentId);
+
+          let cardArray = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
+          cardArray.appendElement(node.item);
+          parentNode.item.deleteCards(cardArray);
+        },
+
+        onCreated: new EventManager({
+          context,
+          name: "contacts.onCreated",
+          register: fire => {
+            let listener = (event, node) => {
+              fire.sync(cache.convert(node));
+            };
+
+            cache.on("contact-created", listener);
+            return () => {
+              cache.off("contact-created", listener);
+            };
+          },
+        }).api(),
+        onUpdated: new EventManager({
+          context,
+          name: "contacts.onUpdated",
+          register: fire => {
+            let listener = (event, node) => {
+              fire.sync(cache.convert(node));
+            };
+
+            cache.on("contact-updated", listener);
+            return () => {
+              cache.off("contact-updated", listener);
+            };
+          },
+        }).api(),
+        onDeleted: new EventManager({
+          context,
+          name: "contacts.onDeleted",
+          register: fire => {
+            let listener = (event, parent, item) => {
+              fire.sync(parent.UID, item.UID);
+            };
+
+            cache.on("contact-deleted", listener);
+            return () => {
+              cache.off("contact-deleted", listener);
+            };
+          },
+        }).api(),
+      },
+      mailingLists: {
+        list(parentId) {
+          let parentNode = cache.findAddressBookById(parentId);
+          return cache.convert(parentNode.mailingLists, false);
+        },
+        get(id) {
+          return cache.convert(cache.findMailingListById(id), false);
+        },
+        create(parentId, { name, nickName, description }) {
+          let mailList = Cc["@mozilla.org/addressbook/directoryproperty;1"].createInstance();
+          mailList.QueryInterface(Ci.nsIAbDirectory);
+          mailList.isMailList = true;
+          mailList.dirName = name;
+          mailList.listNickName = (nickName === null) ? "" : nickName;
+          mailList.description = (description === null) ? "" : description;
+
+          let parentNode = cache.findAddressBookById(parentId);
+          let newMailList = parentNode.item.addMailList(mailList);
+          return newMailList.UID;
+        },
+        update(id, { name, nickName, description }) {
+          let node = cache.findMailingListById(id);
+          node.item.dirName = name;
+          node.item.listNickName = (nickName === null) ? "" : nickName;
+          node.item.description = (description === null) ? "" : description;
+          node.item.editMailListToDatabase(null);
+        },
+        delete(id) {
+          let node = cache.findMailingListById(id);
+          MailServices.ab.deleteAddressBook(node.item.URI);
+        },
+
+        listMembers(id) {
+          let node = cache.findMailingListById(id);
+          return cache.convert(node.contacts, false);
+        },
+        addMember(id, contactId) {
+          let node = cache.findMailingListById(id);
+          let contactNode = cache.findContactById(contactId);
+          node.item.addCard(contactNode.item);
+        },
+        removeMember(id, contactId) {
+          let node = cache.findMailingListById(id);
+          let contactNode = cache.findContactById(contactId);
+
+          let cardArray = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
+          cardArray.appendElement(contactNode.item);
+          node.item.deleteCards(cardArray);
+        },
+
+        onCreated: new EventManager({
+          context,
+          name: "mailingLists.onCreated",
+          register: fire => {
+            let listener = (event, node) => {
+              fire.sync(cache.convert(node));
+            };
+
+            cache.on("mailing-list-created", listener);
+            return () => {
+              cache.off("mailing-list-created", listener);
+            };
+          },
+        }).api(),
+        onUpdated: new EventManager({
+          context,
+          name: "mailingLists.onUpdated",
+          register: fire => {
+            let listener = (event, node) => {
+              fire.sync(cache.convert(node));
+            };
+
+            cache.on("mailing-list-updated", listener);
+            return () => {
+              cache.off("mailing-list-updated", listener);
+            };
+          },
+        }).api(),
+        onDeleted: new EventManager({
+          context,
+          name: "mailingLists.onDeleted",
+          register: fire => {
+            let listener = (event, parent, item) => {
+              fire.sync(parent.UID, item.UID);
+            };
+
+            cache.on("mailing-list-deleted", listener);
+            return () => {
+              cache.off("mailing-list-deleted", listener);
+            };
+          },
+        }).api(),
+        onMemberAdded: new EventManager({
+          context,
+          name: "mailingLists.onMemberAdded",
+          register: fire => {
+            let listener = (event, node) => {
+              fire.sync(cache.convert(node));
+            };
+
+            cache.on("mailing-list-member-added", listener);
+            return () => {
+              cache.off("mailing-list-member-added", listener);
+            };
+          },
+        }).api(),
+        onMemberRemoved: new EventManager({
+          context,
+          name: "mailingLists.onMemberRemoved",
+          register: fire => {
+            let listener = (event, parent, item) => {
+              fire.sync(parent.UID, item.UID);
+            };
+
+            cache.on("mailing-list-member-removed", listener);
+            return () => {
+              cache.off("mailing-list-member-removed", listener);
+            };
+          },
+        }).api(),
+      },
+    };
+  }
+};
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/schemas/addressBook.json
@@ -0,0 +1,600 @@
+[
+  {
+    "namespace": "manifest",
+    "types": [
+      {
+        "$extend": "OptionalPermission",
+        "choices": [
+          {
+            "type": "string",
+            "enum": [
+              "addressBooks"
+            ]
+          }
+        ]
+      }
+    ]
+  },
+  {
+    "namespace": "addressBooks",
+    "permissions": [
+      "addressBooks"
+    ],
+    "types": [
+      {
+        "id": "NodeType",
+        "type": "string",
+        "enum": [
+          "addressBook",
+          "contact",
+          "mailingList"
+        ],
+        "description": "Indicates the type of a Node, which can be one of addressBook, contact, or mailingList."
+      },
+      {
+        "id": "Node",
+        "type": "object",
+        "description": "A node (either an address book, contact, or mailing list) in the address book hierarchy.",
+        "properties": {
+          "id": {
+            "type": "string",
+            "description": "The unique identifier for the node. IDs are unique within the current profile, and they remain valid even after the program is restarted."
+          },
+          "parentId": {
+            "type": "string",
+            "optional": true,
+            "description": "The <code>id</code> of the parent object."
+          },
+          "type": {
+            "$ref": "NodeType",
+            "description": "Indicates the type of a Node, which can be one of addressBook, contact, or mailingList."
+          },
+          "readOnly": {
+            "type": "boolean",
+            "optional": true,
+            "description": "Indicates if the object is read-only. Currently this returns false in all cases, as read-only address books are ignored by the API."
+          }
+        }
+      },
+      {
+        "id": "AddressBookNode",
+        "$extend": "Node",
+        "description": "A node representing an address book.",
+        "properties": {
+          "name": {
+            "type": "string"
+          },
+          "contacts": {
+            "type": "array",
+            "optional": true,
+            "items": {
+              "$ref": "Node"
+            },
+            "description": "A list of contacts held by this node's address book or mailing list."
+          },
+          "mailingLists": {
+            "type": "array",
+            "optional": true,
+            "items": {
+              "$ref": "Node"
+            },
+            "description": "A list of mailingLists in this node's address book."
+          }
+        }
+      }
+    ],
+    "functions": [
+      {
+        "name": "openUI",
+        "type": "function",
+        "description": "Opens the address book user interface.",
+        "parameters": []
+      },
+      {
+        "name": "closeUI",
+        "type": "function",
+        "description": "Closes the address book user interface.",
+        "parameters": []
+      },
+      {
+        "name": "list",
+        "type": "function",
+        "async": true,
+        "parameters": [
+          {
+            "name": "complete",
+            "type": "boolean",
+            "optional": true,
+            "default": false,
+            "description": "If set to true, results will include contacts and mailing lists for each address book."
+          }
+        ],
+        "description": "Gets a list of the user's address books, optionally including all contacts and mailing lists."
+      },
+      {
+        "name": "get",
+        "type": "function",
+        "async": true,
+        "parameters": [
+          {
+            "name": "id",
+            "type": "string"
+          },
+          {
+            "name": "complete",
+            "type": "boolean",
+            "optional": true,
+            "default": false,
+            "description": "If set to true, results will include contacts and mailing lists for this address book."
+          }
+        ],
+        "description": "Gets a single address book, optionally including all contacts and mailing lists."
+      },
+      {
+        "name": "create",
+        "type": "function",
+        "async": true,
+        "parameters": [
+          {
+            "name": "properties",
+            "type": "object",
+            "properties": {
+              "name": {
+                "type": "string"
+              }
+            }
+          }
+        ],
+        "description": "Creates a new, empty address book."
+      },
+      {
+        "name": "update",
+        "type": "function",
+        "async": true,
+        "parameters": [
+          {
+            "name": "id",
+            "type": "string"
+          },
+          {
+            "name": "properties",
+            "type": "object",
+            "properties": {
+              "name": {
+                "type": "string"
+              }
+            }
+          }
+        ],
+        "description": "Renames an address book."
+      },
+      {
+        "name": "delete",
+        "type": "function",
+        "async": true,
+        "parameters": [
+          {
+            "name": "id",
+            "type": "string"
+          }
+        ],
+        "description": "Removes an address book, and all associated contacts and mailing lists."
+      }
+    ],
+    "events": [
+      {
+        "name": "onCreated",
+        "type": "function",
+        "description": "Fired when an address book is created.",
+        "parameters": [
+          {
+            "name": "node",
+            "$ref": "AddressBookNode"
+          }
+        ]
+      },
+      {
+        "name": "onUpdated",
+        "type": "function",
+        "description": "Fired when an address book is renamed.",
+        "parameters": [
+          {
+            "name": "node",
+            "$ref": "AddressBookNode"
+          }
+        ]
+      },
+      {
+        "name": "onDeleted",
+        "type": "function",
+        "description": "Fired when an addressBook is deleted.",
+        "parameters": [
+          {
+            "name": "id",
+            "type": "string"
+          }
+        ]
+      }
+    ]
+  },
+  {
+    "namespace": "contacts",
+    "permissions": [
+      "addressBooks"
+    ],
+    "types": [
+      {
+        "id": "ContactNode",
+        "$extend": "Node",
+        "description": "A node representing a contact in an address book.",
+        "type": "object",
+        "properties": {
+          "properties": {
+            "$ref": "ContactProperties"
+          }
+        }
+      },
+      {
+        "id": "ContactProperties",
+        "type": "object",
+        "description": "A set of properties for a particular contact. For a complete list of properties that Thunderbird uses, see https://hg.mozilla.org/comm-central/file/tip/mailnews/addrbook/public/nsIAbCard.idl",
+        "patternProperties": {
+          "^\\w+$": {
+            "type": "string",
+            "optional": true
+          }
+        }
+      }
+    ],
+    "functions": [
+      {
+        "name": "list",
+        "type": "function",
+        "async": true,
+        "parameters": [
+          {
+            "name": "parentId",
+            "type": "string"
+          }
+        ],
+        "description": "Gets all the contacts in the address book with the id <code>parentId</code>."
+      },
+      {
+        "name": "get",
+        "type": "function",
+        "async": true,
+        "parameters": [
+          {
+            "name": "id",
+            "type": "string"
+          }
+        ],
+        "description": "Gets a single contact."
+      },
+      {
+        "name": "create",
+        "type": "function",
+        "async": true,
+        "parameters": [
+          {
+            "name": "parentId",
+            "type": "string"
+          },
+          {
+            "name": "properties",
+            "$ref": "ContactProperties"
+          }
+        ],
+        "description": "Adds a new contact to the address book with the id <code>parentId</code>."
+      },
+      {
+        "name": "update",
+        "type": "function",
+        "async": true,
+        "parameters": [
+          {
+            "name": "id",
+            "type": "string"
+          },
+          {
+            "name": "properties",
+            "$ref": "ContactProperties"
+          }
+        ],
+        "description": "Edits the properties of a contact. To remove a property, specify it as <code>null</code>."
+      },
+      {
+        "name": "delete",
+        "type": "function",
+        "async": true,
+        "parameters": [
+          {
+            "name": "id",
+            "type": "string"
+          }
+        ],
+        "description": "Removes a contact from the address book. The contact is also removed from any mailing lists it is a member of."
+      }
+    ],
+    "events": [
+      {
+        "name": "onCreated",
+        "type": "function",
+        "description": "Fired when a contact is created.",
+        "parameters": [
+          {
+            "name": "node",
+            "$ref": "ContactNode"
+          },
+          {
+            "name": "id",
+            "type": "string"
+          }
+        ]
+      },
+      {
+        "name": "onUpdated",
+        "type": "function",
+        "description": "Fired when a contact is changed.",
+        "parameters": [
+          {
+            "name": "node",
+            "$ref": "ContactNode"
+          }
+        ]
+      },
+      {
+        "name": "onDeleted",
+        "type": "function",
+        "description": "Fired when a contact is removed from an address book.",
+        "parameters": [
+          {
+            "name": "parentId",
+            "type": "string"
+          },
+          {
+            "name": "id",
+            "type": "string"
+          }
+        ]
+      }
+    ]
+  },
+  {
+    "namespace": "mailingLists",
+    "permissions": [
+      "addressBooks"
+    ],
+    "types": [
+      {
+        "id": "MailingListNode",
+        "$extend": "Node",
+        "description": "A node representing a mailing list.",
+        "properties": {
+          "name": {
+            "type": "string"
+          },
+          "nickName": {
+            "type": "string"
+          },
+          "description": {
+            "type": "string"
+          },
+          "contacts": {
+            "type": "array",
+            "optional": true,
+            "items": {
+              "$ref": "Node"
+            },
+            "description": "A list of contacts held by this node's address book or mailing list."
+          }
+        }
+      }
+    ],
+    "functions": [
+      {
+        "name": "list",
+        "type": "function",
+        "async": true,
+        "parameters": [
+          {
+            "name": "parentId",
+            "type": "string"
+          }
+        ],
+        "description": "Gets all the mailing lists in the address book with id <code>parentId</code>."
+      },
+      {
+        "name": "get",
+        "type": "function",
+        "async": true,
+        "parameters": [
+          {
+            "name": "id",
+            "type": "string"
+          }
+        ],
+        "description": "Gets a single mailing list."
+      },
+      {
+        "name": "create",
+        "type": "function",
+        "async": true,
+        "parameters": [
+          {
+            "name": "parentId",
+            "type": "string"
+          },
+          {
+            "name": "properties",
+            "type": "object",
+            "properties": {
+              "name": {
+                "type": "string"
+              },
+              "nickName": {
+                "type": "string",
+                "optional": true
+              },
+              "description": {
+                "type": "string",
+                "optional": true
+              }
+            }
+          }
+        ],
+        "description": "Creates a new mailing list in the address book with id <code>parentId</code>."
+      },
+      {
+        "name": "update",
+        "type": "function",
+        "async": true,
+        "parameters": [
+          {
+            "name": "id",
+            "type": "string"
+          },
+          {
+            "name": "properties",
+            "type": "object",
+            "properties": {
+              "name": {
+                "type": "string"
+              },
+              "nickName": {
+                "type": "string",
+                "optional": true
+              },
+              "description": {
+                "type": "string",
+                "optional": true
+              }
+            }
+          }
+        ],
+        "description": "Edits the properties of a mailing list."
+      },
+      {
+        "name": "delete",
+        "type": "function",
+        "async": true,
+        "parameters": [
+          {
+            "name": "id",
+            "type": "string"
+          }
+        ],
+        "description": "Removes the mailing list."
+      },
+      {
+        "name": "addMember",
+        "type": "function",
+        "async": true,
+        "parameters": [
+          {
+            "name": "id",
+            "type": "string"
+          },
+          {
+            "name": "contactId",
+            "type": "string"
+          }
+        ],
+        "description": "Adds a contact to the mailing list with id <code>id</code>. If the contact and mailing list are in different address books, the contact will also be copied to the list's address book."
+      },
+      {
+        "name": "listMembers",
+        "type": "function",
+        "async": true,
+        "parameters": [
+          {
+            "name": "id",
+            "type": "string"
+          }
+        ],
+        "description": "Gets all contacts that are members of the mailing list with id <code>id</code>."
+      },
+      {
+        "name": "removeMember",
+        "type": "function",
+        "async": true,
+        "parameters": [
+          {
+            "name": "id",
+            "type": "string"
+          },
+          {
+            "name": "contactId",
+            "type": "string"
+          }
+        ],
+        "description": "Removes a contact from the mailing list with id <code>id</code>. This does not delete the contact from the address book."
+      }
+    ],
+    "events": [
+      {
+        "name": "onCreated",
+        "type": "function",
+        "description": "Fired when a mailing list is created.",
+        "parameters": [
+          {
+            "name": "node",
+            "$ref": "MailingListNode"
+          }
+        ]
+      },
+      {
+        "name": "onUpdated",
+        "type": "function",
+        "description": "Fired when a mailing list is changed.",
+        "parameters": [
+          {
+            "name": "node",
+            "$ref": "MailingListNode"
+          }
+        ]
+      },
+      {
+        "name": "onDeleted",
+        "type": "function",
+        "description": "Fired when a mailing list is deleted.",
+        "parameters": [
+          {
+            "name": "parentId",
+            "type": "string"
+          },
+          {
+            "name": "id",
+            "type": "string"
+          }
+        ]
+      },
+      {
+        "name": "onMemberAdded",
+        "type": "function",
+        "description": "Fired when a contact is added to the mailing list.",
+        "parameters": [
+          {
+            "name": "node",
+            "$ref": "ContactNode"
+          }
+        ]
+      },
+      {
+        "name": "onMemberRemoved",
+        "type": "function",
+        "description": "Fired when a contact is removed from the mailing list.",
+        "parameters": [
+          {
+            "name": "parentId",
+            "type": "string"
+          },
+          {
+            "name": "id",
+            "type": "string"
+          }
+        ]
+      }
+    ]
+  }
+]
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/test/xpcshell/.eslintrc.js
@@ -0,0 +1,12 @@
+"use strict";
+/* eslint-env node */
+
+module.exports = {
+  "extends": "plugin:mozilla/xpcshell-test",
+
+  "env": {
+    // The tests in this folder are testing based on WebExtensions, so lets
+    // just define the webextensions environment here.
+    "webextensions": true,
+  },
+};
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/test/xpcshell/test_ext_addressBook.js
@@ -0,0 +1,557 @@
+/* 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/Services.jsm");
+ChromeUtils.import("resource:///modules/MailServices.jsm");
+ChromeUtils.import("resource://testing-common/ExtensionXPCShellUtils.jsm");
+
+ExtensionTestUtils.init(this);
+
+add_task(async function test_addressBooks() {
+  async function background() {
+    let firstBookId, secondBookId, newContactId;
+
+    let events = [];
+    for (let eventNamespace of ["addressBooks", "contacts", "mailingLists"]) {
+      for (let eventName of ["onCreated", "onUpdated", "onDeleted", "onMemberAdded", "onMemberRemoved"]) {
+        if (eventName in browser[eventNamespace]) {
+          browser[eventNamespace][eventName].addListener((...args) => {
+            events.push({ namespace: eventNamespace, name: eventName, args });
+          });
+        }
+      }
+    }
+
+    let checkEvents = function(...expectedEvents) {
+      browser.test.assertEq(expectedEvents.length, events.length, "Correct number of events");
+
+      if (expectedEvents.length != events.length) {
+        for (let event of events) {
+          let args = event.args.join(", ");
+          browser.test.log(`${event.namespace}.${event.name}(${args})`);
+        }
+        throw new Error("Wrong number of events, stopping.");
+      }
+
+      for (let [namespace, name, ...expectedArgs] of expectedEvents) {
+        let event = events.shift();
+        browser.test.assertEq(namespace, event.namespace, "Event namespace is correct");
+        browser.test.assertEq(name, event.name, "Event type is correct");
+        browser.test.assertEq(expectedArgs.length, event.args.length, "Argument count is correct");
+        for (let i = 0; i < expectedArgs.length; i++) {
+          if (typeof expectedArgs[i] == "object") {
+            for (let k of Object.keys(expectedArgs[i])) {
+              browser.test.assertEq(expectedArgs[i][k], event.args[i][k], `Property ${k} is correct`);
+            }
+          } else {
+            browser.test.assertEq(expectedArgs[i], event.args[i], `Argument ${i + 1} is correct`);
+          }
+        }
+        if (expectedEvents.length == 1) {
+          return event.args;
+        }
+      }
+
+      return null;
+    };
+
+    let awaitMessage = function() {
+      return new Promise(resolve => {
+        browser.test.onMessage.addListener(function listener(...args) {
+          browser.test.onMessage.removeListener(listener);
+          resolve(args);
+        });
+      });
+    };
+
+    async function addressBookTest() {
+      browser.test.log("Starting addressBookTest");
+      let list = await browser.addressBooks.list();
+      browser.test.assertEq(2, list.length);
+      for (let b of list) {
+        browser.test.assertEq(4, Object.keys(b).length);
+        browser.test.assertEq(36, b.id.length);
+        browser.test.assertEq("addressBook", b.type);
+        browser.test.assertTrue("name" in b);
+        browser.test.assertFalse(b.readOnly);
+      }
+
+      let completeList = await browser.addressBooks.list(true);
+      browser.test.assertEq(2, completeList.length);
+      for (let b of completeList) {
+        browser.test.assertEq(6, Object.keys(b).length);
+      }
+
+      firstBookId = list[0].id;
+      secondBookId = list[1].id;
+
+      let firstBook = await browser.addressBooks.get(firstBookId);
+      browser.test.assertEq(4, Object.keys(firstBook).length);
+
+      let secondBook = await browser.addressBooks.get(secondBookId, true);
+      browser.test.assertEq(6, Object.keys(secondBook).length);
+      browser.test.assertTrue(Array.isArray(secondBook.contacts));
+      browser.test.assertEq(0, secondBook.contacts.length);
+      browser.test.assertTrue(Array.isArray(secondBook.mailingLists));
+      browser.test.assertEq(0, secondBook.mailingLists.length);
+
+      let newBookId = await browser.addressBooks.create({ name: "test name" });
+      browser.test.assertEq(36, newBookId.length);
+      checkEvents(["addressBooks", "onCreated", { type: "addressBook", id: newBookId }]);
+
+      list = await browser.addressBooks.list();
+      browser.test.assertEq(3, list.length);
+
+      let newBook = await browser.addressBooks.get(newBookId);
+      browser.test.assertEq(newBookId, newBook.id);
+      browser.test.assertEq("addressBook", newBook.type);
+      browser.test.assertEq("test name", newBook.name);
+
+      await browser.addressBooks.update(newBookId, { name: "new name" });
+      checkEvents(["addressBooks", "onUpdated", { type: "addressBook", id: newBookId }]);
+      let updatedBook = await browser.addressBooks.get(newBookId);
+      browser.test.assertEq("new name", updatedBook.name);
+
+      list = await browser.addressBooks.list();
+      browser.test.assertEq(3, list.length);
+
+      await browser.addressBooks.delete(newBookId);
+      checkEvents(["addressBooks", "onDeleted", newBookId]);
+
+      list = await browser.addressBooks.list();
+      browser.test.assertEq(2, list.length);
+
+      for (let operation of ["get", "update", "delete"]) {
+        let args = [newBookId];
+        if (operation == "update") {
+          args.push({ name: "" });
+        }
+
+        try {
+          await browser.addressBooks[operation].apply(browser.addressBooks, args);
+          browser.test.fail(`Calling ${operation} on a non-existent address book should throw`);
+        } catch (ex) {
+          browser.test.assertEq(
+            `addressBook with id=${newBookId} could not be found.`,
+            ex.message, `browser.addressBooks.${operation} threw exception`
+          );
+        }
+      }
+
+      browser.test.assertEq(0, events.length, "No events left unconsumed");
+      browser.test.log("Completed addressBookTest");
+    }
+
+    async function contactsTest() {
+      browser.test.log("Starting contactsTest");
+      let contacts = await browser.contacts.list(firstBookId);
+      browser.test.assertTrue(Array.isArray(contacts));
+      browser.test.assertEq(0, contacts.length);
+
+      newContactId = await browser.contacts.create(firstBookId, {
+        FirstName: "first",
+        LastName: "last",
+      });
+      browser.test.assertEq(36, newContactId.length);
+      checkEvents(["contacts", "onCreated", { type: "contact", parentId: firstBookId, id: newContactId }]);
+
+      contacts = await browser.contacts.list(firstBookId);
+      browser.test.assertEq(1, contacts.length, "Contact added to first book.");
+      browser.test.assertEq(contacts[0].id, newContactId);
+
+      contacts = await browser.contacts.list(secondBookId);
+      browser.test.assertEq(0, contacts.length, "Contact not added to second book.");
+
+      let newContact = await browser.contacts.get(newContactId);
+      browser.test.assertEq(4, Object.keys(newContact).length);
+      browser.test.assertEq(newContactId, newContact.id);
+      browser.test.assertEq(firstBookId, newContact.parentId);
+      browser.test.assertEq("contact", newContact.type);
+      browser.test.assertEq(3, Object.keys(newContact.properties).length);
+      browser.test.assertEq("0", newContact.properties.PreferMailFormat);
+      browser.test.assertEq("first", newContact.properties.FirstName);
+      browser.test.assertEq("last", newContact.properties.LastName);
+
+      await browser.contacts.update(newContactId, {
+        PrimaryEmail: "first@last",
+        LastName: null,
+      });
+      checkEvents(["contacts", "onUpdated", { type: "contact", parentId: firstBookId, id: newContactId }]);
+
+      let updatedContact = await browser.contacts.get(newContactId);
+      browser.test.assertEq(3, Object.keys(updatedContact.properties).length);
+      browser.test.assertEq("0", updatedContact.properties.PreferMailFormat);
+      browser.test.assertEq("first", updatedContact.properties.FirstName);
+      browser.test.assertEq("first@last", updatedContact.properties.PrimaryEmail);
+      browser.test.assertTrue(!("LastName" in updatedContact.properties));
+
+      browser.test.assertEq(0, events.length, "No events left unconsumed");
+      browser.test.log("Completed contactsTest");
+    }
+
+    async function mailingListsTest() {
+      browser.test.log("Starting mailingListsTest");
+      let mailingLists = await browser.mailingLists.list(firstBookId);
+      browser.test.assertTrue(Array.isArray(mailingLists));
+      browser.test.assertEq(0, mailingLists.length);
+
+      let newMailingListId = await browser.mailingLists.create(firstBookId, { name: "name" });
+      browser.test.assertEq(36, newMailingListId.length);
+      checkEvents(
+        ["mailingLists", "onCreated", { type: "mailingList", parentId: firstBookId, id: newMailingListId }]
+      );
+
+      mailingLists = await browser.mailingLists.list(firstBookId);
+      browser.test.assertEq(1, mailingLists.length, "List added to first book.");
+
+      mailingLists = await browser.mailingLists.list(secondBookId);
+      browser.test.assertEq(0, mailingLists.length, "List not added to second book.");
+
+      let newAddressList = await browser.mailingLists.get(newMailingListId);
+      browser.test.assertEq(6, Object.keys(newAddressList).length);
+      browser.test.assertEq(newMailingListId, newAddressList.id);
+      browser.test.assertEq(firstBookId, newAddressList.parentId);
+      browser.test.assertEq("mailingList", newAddressList.type);
+      browser.test.assertEq("name", newAddressList.name);
+      browser.test.assertEq("", newAddressList.nickName);
+      browser.test.assertEq("", newAddressList.description);
+
+      await browser.mailingLists.update(newMailingListId, {
+        name: "name!",
+        nickName: "nickname!",
+        description: "description!",
+      });
+      checkEvents(
+        ["mailingLists", "onUpdated", { type: "mailingList", parentId: firstBookId, id: newMailingListId }]
+      );
+
+      let updatedMailingList = await browser.mailingLists.get(newMailingListId);
+      browser.test.assertEq("name!", updatedMailingList.name);
+      browser.test.assertEq("nickname!", updatedMailingList.nickName);
+      browser.test.assertEq("description!", updatedMailingList.description);
+
+      await browser.mailingLists.addMember(newMailingListId, newContactId);
+      checkEvents(
+        ["mailingLists", "onMemberAdded", { type: "contact", parentId: newMailingListId, id: newContactId }]
+      );
+
+      let listMembers = await browser.mailingLists.listMembers(newMailingListId);
+      browser.test.assertTrue(Array.isArray(listMembers));
+      browser.test.assertEq(1, listMembers.length);
+
+      let anotherContactId = await browser.contacts.create(firstBookId, {
+        FirstName: "second",
+        LastName: "last",
+        PrimaryEmail: "em@il",
+      });
+      checkEvents(["contacts", "onCreated", { type: "contact", parentId: firstBookId, id: anotherContactId }]);
+
+      await browser.mailingLists.addMember(newMailingListId, anotherContactId);
+      checkEvents(
+        ["mailingLists", "onMemberAdded", { type: "contact", parentId: newMailingListId, id: anotherContactId }]
+      );
+
+      listMembers = await browser.mailingLists.listMembers(newMailingListId);
+      browser.test.assertEq(2, listMembers.length);
+
+      await browser.contacts.delete(anotherContactId);
+      checkEvents(
+        ["contacts", "onDeleted", firstBookId, anotherContactId],
+        ["mailingLists", "onMemberRemoved", newMailingListId, anotherContactId]
+      );
+      listMembers = await browser.mailingLists.listMembers(newMailingListId);
+      browser.test.assertEq(1, listMembers.length);
+
+      await browser.mailingLists.removeMember(newMailingListId, newContactId);
+      checkEvents(
+        ["mailingLists", "onMemberRemoved", newMailingListId, newContactId]
+      );
+      listMembers = await browser.mailingLists.listMembers(newMailingListId);
+      browser.test.assertEq(0, listMembers.length);
+
+      await browser.mailingLists.delete(newMailingListId);
+      checkEvents(["mailingLists", "onDeleted", firstBookId, newMailingListId]);
+
+      mailingLists = await browser.mailingLists.list(firstBookId);
+      browser.test.assertEq(0, mailingLists.length);
+
+      for (let operation of ["get", "update", "delete", "listMembers", "addMember", "removeMember"]) {
+        let args = [newMailingListId];
+        switch (operation) {
+          case "update":
+            args.push({ name: "" });
+            break;
+          case "addMember":
+          case "removeMember":
+            args.push(newContactId);
+            break;
+        }
+
+        try {
+          await browser.mailingLists[operation].apply(browser.mailingLists, args);
+          browser.test.fail(`Calling ${operation} on a non-existent mailing list should throw`);
+        } catch (ex) {
+          browser.test.assertEq(
+            `mailingList with id=${newMailingListId} could not be found.`,
+            ex.message, `browser.mailingLists.${operation} threw exception`
+          );
+        }
+      }
+
+      browser.test.assertEq(0, events.length, "No events left unconsumed");
+      browser.test.log("Completed mailingListsTest");
+    }
+
+    async function contactRemovalTest() {
+      browser.test.log("Starting contactRemovalTest");
+      await browser.contacts.delete(newContactId);
+      checkEvents(["contacts", "onDeleted", firstBookId, newContactId]);
+
+      for (let operation of ["get", "update", "delete"]) {
+        let args = [newContactId];
+        if (operation == "update") {
+          args.push({});
+        }
+
+        try {
+          await browser.contacts[operation].apply(browser.contacts, args);
+          browser.test.fail(`Calling ${operation} on a non-existent contact should throw`);
+        } catch (ex) {
+          browser.test.assertEq(
+            `contact with id=${newContactId} could not be found.`,
+            ex.message, `browser.contacts.${operation} threw exception`
+          );
+        }
+      }
+
+      let contacts = await browser.contacts.list(firstBookId);
+      browser.test.assertEq(0, contacts.length);
+
+      browser.test.assertEq(0, events.length, "No events left unconsumed");
+      browser.test.log("Completed contactRemovalTest");
+    }
+
+    async function outsideEventsTest() {
+      browser.test.log("Starting outsideEventsTest");
+
+      browser.test.sendMessage("outsideEventsTest", "createAddressBook");
+      let [bookId, newBookPrefId] = await awaitMessage();
+      let [newBook] = checkEvents(["addressBooks", "onCreated", { type: "addressBook", id: bookId }]);
+      browser.test.assertEq("external add", newBook.name);
+
+      browser.test.sendMessage("outsideEventsTest", "updateAddressBook", newBookPrefId);
+      await awaitMessage();
+      let [updatedBook] = checkEvents(["addressBooks", "onUpdated", { type: "addressBook", id: bookId }]);
+      browser.test.assertEq("external edit", updatedBook.name);
+
+      browser.test.sendMessage("outsideEventsTest", "deleteAddressBook", newBookPrefId);
+      await awaitMessage();
+      checkEvents(["addressBooks", "onDeleted", bookId]);
+
+      browser.test.sendMessage("outsideEventsTest", "createContact");
+      let [parentId1, contactId] = await awaitMessage();
+      let [newContact] = checkEvents(
+        ["contacts", "onCreated", { type: "contact", parentId: parentId1, id: contactId }]
+      );
+      browser.test.assertEq("external", newContact.properties.FirstName);
+      browser.test.assertEq("add", newContact.properties.LastName);
+
+      browser.test.sendMessage("outsideEventsTest", "updateContact", contactId);
+      await awaitMessage();
+      let [updatedContact] = checkEvents(
+        ["contacts", "onUpdated", { type: "contact", parentId: parentId1, id: contactId }]
+      );
+      browser.test.assertEq("external", updatedContact.properties.FirstName);
+      browser.test.assertEq("edit", updatedContact.properties.LastName);
+
+      browser.test.sendMessage("outsideEventsTest", "createMailingList");
+      let [parentId2, listId] = await awaitMessage();
+      let [newList] = checkEvents(
+        ["mailingLists", "onCreated", { type: "mailingList", parentId: parentId2, id: listId }]
+      );
+      browser.test.assertEq("external add", newList.name);
+
+      browser.test.sendMessage("outsideEventsTest", "updateMailingList", listId);
+      await awaitMessage();
+      let [updatedList] = checkEvents(
+        ["mailingLists", "onUpdated", { type: "mailingList", parentId: parentId2, id: listId }]
+      );
+      browser.test.assertEq("external edit", updatedList.name);
+
+      browser.test.sendMessage("outsideEventsTest", "addMailingListMember", listId, contactId);
+      await awaitMessage();
+      checkEvents(
+        ["mailingLists", "onMemberAdded", { type: "contact", parentId: listId, id: contactId }]
+      );
+      let listMembers = await browser.mailingLists.listMembers(listId);
+      browser.test.assertEq(1, listMembers.length);
+
+      browser.test.sendMessage("outsideEventsTest", "removeMailingListMember", listId, contactId);
+      await awaitMessage();
+      checkEvents(
+        ["mailingLists", "onMemberRemoved", listId, contactId]
+      );
+
+      browser.test.sendMessage("outsideEventsTest", "deleteMailingList", listId);
+      await awaitMessage();
+      checkEvents(["mailingLists", "onDeleted", parentId2, listId]);
+
+      browser.test.sendMessage("outsideEventsTest", "deleteContact", contactId);
+      await awaitMessage();
+      checkEvents(["contacts", "onDeleted", parentId1, contactId]);
+
+      browser.test.log("Completed outsideEventsTest");
+    }
+
+    await addressBookTest();
+    await contactsTest();
+    await mailingListsTest();
+    await contactRemovalTest();
+    await outsideEventsTest();
+
+    browser.test.notifyPass("addressBooks");
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background,
+    manifest: { permissions: ["addressBooks"] },
+  });
+
+  extension.onMessage("outsideEventsTest", (action, ...args) => {
+    function findContact(id) {
+      for (let child of parent.childCards) {
+        if (child.UID == id) {
+          return child;
+        }
+      }
+      return null;
+    }
+    function findMailingList(id) {
+      for (let list of parent.addressLists.enumerate()) {
+        if (list.UID == id) {
+          return list;
+        }
+      }
+      return null;
+    }
+
+    let parent = MailServices.ab.directories.getNext().QueryInterface(Ci.nsIAbDirectory);
+    switch (action) {
+      case "createAddressBook": {
+        const kPABDirectory = 2; // defined in nsDirPrefs.h
+        let dirPrefId = MailServices.ab.newAddressBook("external add", "", kPABDirectory);
+        let book = MailServices.ab.getDirectoryFromId(dirPrefId);
+        extension.sendMessage(book.UID, dirPrefId);
+        return;
+      }
+      case "updateAddressBook": {
+        let book = MailServices.ab.getDirectoryFromId(args[0]);
+        book.dirName = "external edit";
+        extension.sendMessage();
+        return;
+      }
+      case "deleteAddressBook": {
+        let book = MailServices.ab.getDirectoryFromId(args[0]);
+        MailServices.ab.deleteAddressBook(book.URI);
+        extension.sendMessage();
+        return;
+      }
+
+      case "createContact": {
+        let contact = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(Ci.nsIAbCard);
+        contact.setProperty("FirstName", "external");
+        contact.setProperty("LastName", "add");
+        let newContact = parent.addCard(contact);
+        extension.sendMessage(parent.UID, newContact.UID);
+        return;
+      }
+      case "updateContact": {
+        let contact = findContact(args[0]);
+        if (contact) {
+          contact.setProperty("FirstName", "external");
+          contact.setProperty("LastName", "edit");
+          parent.modifyCard(contact);
+          extension.sendMessage();
+          return;
+        }
+        break;
+      }
+      case "deleteContact": {
+        let contact = findContact(args[0]);
+        if (contact) {
+          let cardArray = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
+          cardArray.appendElement(contact);
+          parent.deleteCards(cardArray);
+          extension.sendMessage();
+          return;
+        }
+        break;
+      }
+
+      case "createMailingList": {
+        let list = Cc["@mozilla.org/addressbook/directoryproperty;1"].createInstance();
+        list.QueryInterface(Ci.nsIAbDirectory);
+        list.isMailList = true;
+        list.dirName = "external add";
+
+        let newList = parent.addMailList(list);
+        extension.sendMessage(parent.UID, newList.UID);
+        return;
+      }
+      case "updateMailingList": {
+        let list = findMailingList(args[0]);
+        if (list) {
+          list.dirName = "external edit";
+          list.editMailListToDatabase(null);
+          extension.sendMessage();
+          return;
+        }
+        break;
+      }
+      case "deleteMailingList": {
+        let list = findMailingList(args[0]);
+        if (list) {
+          MailServices.ab.deleteAddressBook(list.URI);
+          extension.sendMessage();
+          return;
+        }
+        break;
+      }
+      case "addMailingListMember": {
+        let list = findMailingList(args[0]);
+        let contact = findContact(args[1]);
+
+        if (list && contact) {
+          list.addCard(contact);
+          equal(1, list.addressLists.Count());
+          extension.sendMessage();
+          return;
+        }
+        break;
+      }
+      case "removeMailingListMember": {
+        let list = findMailingList(args[0]);
+        let contact = findContact(args[1]);
+
+        if (list && contact) {
+          let cardArray = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
+          cardArray.appendElement(contact);
+          list.deleteCards(cardArray);
+          equal(0, list.addressLists.Count());
+          ok(findContact(args[1]), "Contact was not removed");
+          extension.sendMessage();
+          return;
+        }
+        break;
+      }
+    }
+    throw new Error(`Message "${action}" passed to handler didn't do anything.`);
+  });
+
+  await extension.startup();
+  await extension.awaitFinish("addressBooks");
+  await extension.unload();
+});
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/test/xpcshell/xpcshell.ini
@@ -0,0 +1,4 @@
+[default]
+tags = webextensions
+
+[test_ext_addressBook.js]