Bug 1503421 - Context menus WebExtensions API; r=Fallen
authorGeoff Lankow <geoff@darktrojan.net>
Sun, 30 Dec 2018 20:33:02 +1300
changeset 34081 04e98cdf20613f973d96dcbf5ca6bc659c594fa2
parent 34080 4331140b9077f55277917f2a0d8f2d2f9e839e70
child 34082 da68367e23e51f72ccd8e1aec01b76eee6732fbd
push id389
push userclokep@gmail.com
push dateMon, 18 Mar 2019 19:01:53 +0000
reviewersFallen
bugs1503421
Bug 1503421 - Context menus WebExtensions API; r=Fallen
mail/base/content/nsContextMenu.js
mail/base/content/tabmail.xml
mail/components/extensions/child/.eslintrc.js
mail/components/extensions/child/ext-mail.js
mail/components/extensions/child/ext-menus-child.js
mail/components/extensions/child/ext-menus.js
mail/components/extensions/ext-mail.json
mail/components/extensions/jar.mn
mail/components/extensions/parent/ext-mail.js
mail/components/extensions/parent/ext-menus.js
mail/components/extensions/schemas/menus.json
mail/components/extensions/schemas/menus_child.json
mail/components/extensions/test/browser/browser.ini
mail/components/extensions/test/browser/browser_ext_menus.js
--- a/mail/base/content/nsContextMenu.js
+++ b/mail/base/content/nsContextMenu.js
@@ -78,16 +78,42 @@ nsContextMenu.prototype = {
     }
 
     this.isContentSelected = this.isContentSelection();
 
     this.hasPageMenu = false;
     if (!aIsShift) {
       this.hasPageMenu = PageMenuParent.buildAndAddToPopup(this.target,
                                                            aPopup);
+
+      let subject = {
+        menu: aPopup,
+        tab: document.getElementById("tabmail").currentTabInfo,
+        isContentSelected: this.isContentSelected,
+        inFrame: this.inFrame,
+        isTextSelected: this.isTextSelected,
+        onTextInput: this.onTextInput,
+        onLink: this.onLink,
+        onImage: this.onImage,
+        onVideo: this.onVideo,
+        onAudio: this.onAudio,
+        onCanvas: this.onCanvas,
+        onEditableArea: this.onEditableArea,
+        srcUrl: this.mediaURL,
+        pageUrl: this.browser ? this.browser.currentURI.spec : undefined,
+        linkUrl: this.linkURL,
+        selectionText: this.isTextSelected ? this.selectionInfo.text : undefined,
+      };
+      if (document.popupNode.closest("tree") == gFolderDisplay.tree) {
+        subject.displayedFolder = gFolderDisplay.view.displayedFolder;
+        subject.selectedMessages = gFolderDisplay.selectedMessages;
+      }
+      subject.wrappedJSObject = subject;
+
+      Services.obs.notifyObservers(subject, "on-build-contextmenu");
     }
 
     this.initItems();
 
     // If all items in the menu are hidden, set this.shouldDisplay to false
     // so that the callers know to not even display the empty menu.
     let contextPopup = document.getElementById("mailContext");
     for (let item of contextPopup.children) {
--- a/mail/base/content/tabmail.xml
+++ b/mail/base/content/tabmail.xml
@@ -1827,16 +1827,24 @@
 
       <property name="linkedBrowser">
         <getter><![CDATA[
           let tabmail = document.getElementById("tabmail");
           let tab = tabmail._getTabContextForTabbyThing(this, false)[1];
           return tabmail.getBrowserForTab(tab);
         ]]></getter>
       </property>
+
+      <property name="mode">
+        <getter><![CDATA[
+          let tabmail = document.getElementById("tabmail");
+          let tab = tabmail._getTabContextForTabbyThing(this, false)[1];
+          return tab.mode;
+        ]]></getter>
+      </property>
     </implementation>
 
     <handlers>
       <handler event="mouseover">
         var anonid = event.originalTarget.getAttribute("anonid");
         if (anonid == "close-button")
           this.mOverCloseButton = true;
       </handler>
--- a/mail/components/extensions/child/.eslintrc.js
+++ b/mail/components/extensions/child/.eslintrc.js
@@ -1,13 +1,15 @@
 "use strict";
 
 module.exports = {
   "globals": {
     // These are defined in the WebExtension script scopes by ExtensionCommon.jsm.
     // From toolkit/components/extensions/.eslintrc.js.
     "ExtensionAPI": true,
+    "ExtensionCommon": true,
     "extensions": true,
+    "ExtensionUtils": true,
 
     // From toolkit/components/extensions/child/.eslintrc.js.
     "EventManager": true,
   },
 };
--- a/mail/components/extensions/child/ext-mail.js
+++ b/mail/components/extensions/child/ext-mail.js
@@ -1,15 +1,29 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 extensions.registerModules({
+  menus: {
+    url: "chrome://messenger/content/child/ext-menus.js",
+    scopes: ["addon_child"],
+    paths: [
+      ["menus"],
+    ],
+  },
+  menusChild: {
+    url: "chrome://messenger/content/child/ext-menus-child.js",
+    scopes: ["addon_child", "devtools_child"],
+    paths: [
+      ["menus"],
+    ],
+  },
   tabs: {
     url: "chrome://messenger/content/child/ext-tabs.js",
     scopes: ["addon_child"],
     paths: [
       ["tabs"],
     ],
   },
 });
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/child/ext-menus-child.js
@@ -0,0 +1,24 @@
+"use strict";
+
+ChromeUtils.defineModuleGetter(this, "ContextMenuChild",
+                               "resource:///actors/ContextMenuChild.jsm");
+
+this.menusChild = class extends ExtensionAPI {
+  getAPI(context) {
+    return {
+      menus: {
+        getTargetElement(targetElementId) {
+          let element;
+          let lastMenuTarget = ContextMenuChild.getLastTarget(context.messageManager);
+          if (lastMenuTarget && Math.floor(lastMenuTarget.timeStamp) === targetElementId) {
+            element = lastMenuTarget.targetRef.get();
+          }
+          if (element && element.getRootNode({composed: true}) === context.contentWindow.document) {
+            return element;
+          }
+          return null;
+        },
+      },
+    };
+  }
+};
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/child/ext-menus.js
@@ -0,0 +1,260 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+ChromeUtils.defineModuleGetter(this, "Services",
+                               "resource://gre/modules/Services.jsm");
+
+var {
+  withHandlingUserInput,
+} = ExtensionCommon;
+
+var {
+  ExtensionError,
+} = ExtensionUtils;
+
+// If id is not specified for an item we use an integer.
+// This ID need only be unique within a single addon. Since all addon code that
+// can use this API runs in the same process, this local variable suffices.
+var gNextMenuItemID = 0;
+
+// Map[Extension -> Map[string or id, ContextMenusClickPropHandler]]
+var gPropHandlers = new Map();
+
+// The menus API supports an "onclick" attribute in the create/update
+// methods to register a callback. This class manages these onclick properties.
+class ContextMenusClickPropHandler {
+  constructor(context) {
+    this.context = context;
+    // Map[string or integer -> callback]
+    this.onclickMap = new Map();
+    this.dispatchEvent = this.dispatchEvent.bind(this);
+  }
+
+  // A listener on menus.onClicked that forwards the event to the only
+  // listener, if any.
+  dispatchEvent(info, tab) {
+    let onclick = this.onclickMap.get(info.menuItemId);
+    if (onclick) {
+      // No need for runSafe or anything because we are already being run inside
+      // an event handler -- the event is just being forwarded to the actual
+      // handler.
+      withHandlingUserInput(this.context.contentWindow, () => onclick(info, tab));
+    }
+  }
+
+  // Sets the `onclick` handler for the given menu item.
+  // The `onclick` function MUST be owned by `this.context`.
+  setListener(id, onclick) {
+    if (this.onclickMap.size === 0) {
+      this.context.childManager.getParentEvent("menus.onClicked").addListener(this.dispatchEvent);
+      this.context.callOnClose(this);
+    }
+    this.onclickMap.set(id, onclick);
+
+    let propHandlerMap = gPropHandlers.get(this.context.extension);
+    if (!propHandlerMap) {
+      propHandlerMap = new Map();
+    } else {
+      // If the current callback was created in a different context, remove it
+      // from the other context.
+      let propHandler = propHandlerMap.get(id);
+      if (propHandler && propHandler !== this) {
+        propHandler.unsetListener(id);
+      }
+    }
+    propHandlerMap.set(id, this);
+    gPropHandlers.set(this.context.extension, propHandlerMap);
+  }
+
+  // Deletes the `onclick` handler for the given menu item.
+  // The `onclick` function MUST be owned by `this.context`.
+  unsetListener(id) {
+    if (!this.onclickMap.delete(id)) {
+      return;
+    }
+    if (this.onclickMap.size === 0) {
+      this.context.childManager.getParentEvent("menus.onClicked").removeListener(this.dispatchEvent);
+      this.context.forgetOnClose(this);
+    }
+    let propHandlerMap = gPropHandlers.get(this.context.extension);
+    propHandlerMap.delete(id);
+    if (propHandlerMap.size === 0) {
+      gPropHandlers.delete(this.context.extension);
+    }
+  }
+
+  // Deletes the `onclick` handler for the given menu item, if any, regardless
+  // of the context where it was created.
+  unsetListenerFromAnyContext(id) {
+    let propHandlerMap = gPropHandlers.get(this.context.extension);
+    let propHandler = propHandlerMap && propHandlerMap.get(id);
+    if (propHandler) {
+      propHandler.unsetListener(id);
+    }
+  }
+
+  // Remove all `onclick` handlers of the extension.
+  deleteAllListenersFromExtension() {
+    let propHandlerMap = gPropHandlers.get(this.context.extension);
+    if (propHandlerMap) {
+      for (let [id, propHandler] of propHandlerMap) {
+        propHandler.unsetListener(id);
+      }
+    }
+  }
+
+  // Removes all `onclick` handlers from this context.
+  close() {
+    for (let id of this.onclickMap.keys()) {
+      this.unsetListener(id);
+    }
+  }
+}
+
+this.menus = class extends ExtensionAPI {
+  getAPI(context) {
+    let onClickedProp = new ContextMenusClickPropHandler(context);
+    let pendingMenuEvent;
+
+    return {
+      menus: {
+        create(createProperties, callback) {
+          if (createProperties.id === null) {
+            createProperties.id = ++gNextMenuItemID;
+          }
+          let {onclick} = createProperties;
+          delete createProperties.onclick;
+          context.childManager.callParentAsyncFunction("menus.create", [
+            createProperties,
+          ]).then(() => {
+            if (onclick) {
+              onClickedProp.setListener(createProperties.id, onclick);
+            }
+            if (callback) {
+              context.runSafeWithoutClone(callback);
+            }
+          }).catch(error => {
+            context.withLastError(error, null, () => {
+              if (callback) {
+                context.runSafeWithoutClone(callback);
+              }
+            });
+          });
+          return createProperties.id;
+        },
+
+        update(id, updateProperties) {
+          let {onclick} = updateProperties;
+          delete updateProperties.onclick;
+          return context.childManager.callParentAsyncFunction("menus.update", [
+            id,
+            updateProperties,
+          ]).then(() => {
+            if (onclick) {
+              onClickedProp.setListener(id, onclick);
+            } else if (onclick === null) {
+              onClickedProp.unsetListenerFromAnyContext(id);
+            }
+            // else onclick is not set so it should not be changed.
+          });
+        },
+
+        remove(id) {
+          onClickedProp.unsetListenerFromAnyContext(id);
+          return context.childManager.callParentAsyncFunction("menus.remove", [
+            id,
+          ]);
+        },
+
+        removeAll() {
+          onClickedProp.deleteAllListenersFromExtension();
+
+          return context.childManager.callParentAsyncFunction("menus.removeAll", []);
+        },
+
+        overrideContext(contextOptions) {
+          let checkValidArg = (contextType, propKey) => {
+            if (contextOptions.context !== contextType) {
+              if (contextOptions[propKey]) {
+                throw new ExtensionError(`Property "${propKey}" can only be used with context "${contextType}"`);
+              }
+              return false;
+            }
+            if (contextOptions.showDefaults) {
+              throw new ExtensionError(`Property "showDefaults" cannot be used with context "${contextType}"`);
+            }
+            if (!contextOptions[propKey]) {
+              throw new ExtensionError(`Property "${propKey}" is required for context "${contextType}"`);
+            }
+            return true;
+          };
+          if (checkValidArg("tab", "tabId")) {
+            if (!context.extension.hasPermission("tabs")) {
+              throw new ExtensionError(`The "tab" context requires the "tabs" permission.`);
+            }
+          }
+          if (checkValidArg("bookmark", "bookmarkId")) {
+            if (!context.extension.hasPermission("bookmarks")) {
+              throw new ExtensionError(`The "bookmark" context requires the "bookmarks" permission.`);
+            }
+          }
+
+          let webExtContextData = {
+            extensionId: context.extension.id,
+            showDefaults: contextOptions.showDefaults,
+            overrideContext: contextOptions.context,
+            bookmarkId: contextOptions.bookmarkId,
+            tabId: contextOptions.tabId,
+          };
+
+          if (pendingMenuEvent) {
+            // overrideContext is called more than once during the same event.
+            pendingMenuEvent.webExtContextData = webExtContextData;
+            return;
+          }
+          pendingMenuEvent = {
+            webExtContextData,
+            observe(subject, topic, data) {
+              pendingMenuEvent = null;
+              Services.obs.removeObserver(this, "on-prepare-contextmenu");
+              subject = subject.wrappedJSObject;
+              if (context.principal.subsumes(subject.context.principal)) {
+                subject.webExtContextData = this.webExtContextData;
+              }
+            },
+            run() {
+              // "on-prepare-contextmenu" is expected to be observed before the
+              // end of the "contextmenu" event dispatch. This task is queued
+              // in case that does not happen, e.g. when the menu is not shown.
+              // ... or if the method was not called during a contextmenu event.
+              if (pendingMenuEvent === this) {
+                pendingMenuEvent = null;
+                Services.obs.removeObserver(this, "on-prepare-contextmenu");
+              }
+            },
+          };
+          Services.obs.addObserver(pendingMenuEvent, "on-prepare-contextmenu");
+          Services.tm.dispatchToMainThread(pendingMenuEvent);
+        },
+
+        onClicked: new EventManager({
+          context,
+          name: "menus.onClicked",
+          register: fire => {
+            let listener = (info, tab) => {
+              withHandlingUserInput(context.contentWindow,
+                                    () => fire.sync(info, tab));
+            };
+
+            let event = context.childManager.getParentEvent("menus.onClicked");
+            event.addListener(listener);
+            return () => {
+              event.removeListener(listener);
+            };
+          },
+        }).api(),
+      },
+    };
+  }
+};
--- a/mail/components/extensions/ext-mail.json
+++ b/mail/components/extensions/ext-mail.json
@@ -54,16 +54,28 @@
     "url": "chrome://messenger/content/parent/ext-mailTabs.js",
     "schema": "chrome://messenger/content/schemas/mailTabs.json",
     "scopes": ["addon_parent"],
     "manifest": ["mailTabs"],
     "paths": [
       ["mailTabs"]
     ]
   },
+  "menusChild": {
+    "schema": "chrome://messenger/content/schemas/menus_child.json",
+    "scopes": ["addon_child", "content_child", "devtools_child"]
+  },
+  "menus": {
+    "url": "chrome://messenger/content/parent/ext-menus.js",
+    "schema": "chrome://messenger/content/schemas/menus.json",
+    "scopes": ["addon_parent"],
+    "paths": [
+      ["menus"]
+    ]
+  },
   "pkcs11": {
     "url": "chrome://messenger/content/parent/ext-pkcs11.js",
     "schema": "chrome://messenger/content/schemas/pkcs11.json",
     "scopes": ["addon_parent"],
     "paths": [
       ["pkcs11"]
     ]
   },
--- a/mail/components/extensions/jar.mn
+++ b/mail/components/extensions/jar.mn
@@ -2,32 +2,37 @@
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 messenger.jar:
     content/messenger/ext-mail.json                (ext-mail.json)
     content/messenger/extension.svg                (extension.svg)
 
     content/messenger/child/ext-mail.js            (child/ext-mail.js)
+    content/messenger/child/ext-menus-child.js     (child/ext-menus-child.js)
+    content/messenger/child/ext-menus.js           (child/ext-menus.js)
     content/messenger/child/ext-tabs.js            (child/ext-tabs.js)
 
     content/messenger/parent/ext-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-commands.js       (parent/ext-commands.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-mailTabs.js       (parent/ext-mailTabs.js)
+    content/messenger/parent/ext-menus.js          (parent/ext-menus.js)
     content/messenger/parent/ext-pkcs11.js         (../../../../browser/components/extensions/parent/ext-pkcs11.js)
     content/messenger/parent/ext-tabs.js           (parent/ext-tabs.js)
     content/messenger/parent/ext-windows.js        (parent/ext-windows.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/commands.json        (schemas/commands.json)
     content/messenger/schemas/composeAction.json   (schemas/composeAction.json)
     content/messenger/schemas/legacy.json          (schemas/legacy.json)
     content/messenger/schemas/mailTabs.json        (schemas/mailTabs.json)
+    content/messenger/schemas/menus.json           (schemas/menus.json)
+    content/messenger/schemas/menus_child.json     (schemas/menus_child.json)
     content/messenger/schemas/pkcs11.json          (../../../../browser/components/extensions/schemas/pkcs11.json)
     content/messenger/schemas/tabs.json            (schemas/tabs.json)
     content/messenger/schemas/windows.json         (schemas/windows.json)
--- a/mail/components/extensions/parent/ext-mail.js
+++ b/mail/components/extensions/parent/ext-mail.js
@@ -501,16 +501,24 @@ class TabTracker extends TabTrackerBase 
 tabTracker = new TabTracker();
 windowTracker = new WindowTracker();
 Object.assign(global, { tabTracker, windowTracker });
 
 /**
  * Extension-specific wrapper around a Thunderbird tab.
  */
 class Tab extends TabBase {
+  constructor(extension, nativeTab, id) {
+    if (nativeTab.localName == "tab") {
+      let tabmail = nativeTab.ownerDocument.getElementById("tabmail");
+      nativeTab = tabmail._getTabContextForTabbyThing(nativeTab)[1];
+    }
+    super(extension, nativeTab, id);
+  }
+
   /** Returns true if this tab is a 3-pane tab. */
   get isMail3Pane() {
     return this.nativeTab.mode.type == "folder";
   }
 
   /** Overrides the matches function to enable querying for 3-pane tabs. */
   matches(queryInfo, context) {
     let result = super.matches(queryInfo, context);
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/parent/ext-menus.js
@@ -0,0 +1,1043 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+ChromeUtils.import("resource://gre/modules/Services.jsm");
+ChromeUtils.import("resource://gre/modules/AppConstants.jsm");
+
+var {
+  DefaultMap,
+  ExtensionError,
+} = ExtensionUtils;
+
+ChromeUtils.import("resource://gre/modules/ExtensionParent.jsm");
+
+var {
+  IconDetails,
+} = ExtensionParent;
+
+const ACTION_MENU_TOP_LEVEL_LIMIT = 6;
+
+// Map[Extension -> Map[ID -> MenuItem]]
+// Note: we want to enumerate all the menu items so
+// this cannot be a weak map.
+var gMenuMap = new Map();
+
+// Map[Extension -> MenuItem]
+var gRootItems = new Map();
+
+// Map[Extension -> ID[]]
+// Menu IDs that were eligible for being shown in the current menu.
+var gShownMenuItems = new DefaultMap(() => []);
+
+// Set of extensions that are listening to onShown.
+var gOnShownSubscribers = new Set();
+
+// If id is not specified for an item we use an integer.
+var gNextMenuItemID = 0;
+
+// Used to assign unique names to radio groups.
+var gNextRadioGroupID = 0;
+
+// The max length of a menu item's label.
+var gMaxLabelLength = 64;
+
+var gMenuBuilder = {
+  // When a new menu is opened, this function is called and
+  // we populate the |xulMenu| with all the items from extensions
+  // to be displayed. We always clear all the items again when
+  // popuphidden fires.
+  build(contextData) {
+    contextData = this.maybeOverrideContextData(contextData);
+    let xulMenu = contextData.menu;
+    xulMenu.addEventListener("popuphidden", this);
+    this.xulMenu = xulMenu;
+    for (let [, root] of gRootItems) {
+      this.createAndInsertTopLevelElements(root, contextData, null);
+    }
+    this.afterBuildingMenu(contextData);
+
+    if (contextData.webExtContextData && !contextData.webExtContextData.showDefaults) {
+      // Wait until nsContextMenu.js has toggled the visibility of the default
+      // menu items before hiding the default items.
+      Promise.resolve().then(() => this.hideDefaultMenuItems());
+    }
+  },
+
+  maybeOverrideContextData(contextData) {
+    let {webExtContextData} = contextData;
+    if (!webExtContextData || !webExtContextData.overrideContext) {
+      return contextData;
+    }
+    let contextDataBase = {
+      menu: contextData.menu,
+      // eslint-disable-next-line no-use-before-define
+      originalViewType: getContextViewType(contextData),
+      originalViewUrl: contextData.inFrame ? contextData.frameUrl : contextData.pageUrl,
+      webExtContextData,
+    };
+    if (webExtContextData.overrideContext === "tab") {
+      // TODO: Handle invalid tabs more gracefully (instead of throwing).
+      let tab = tabTracker.getTab(webExtContextData.tabId);
+      return {
+        ...contextDataBase,
+        tab,
+        pageUrl: tab.linkedBrowser.currentURI.spec,
+        onTab: true,
+      };
+    }
+    throw new Error(`Unexpected overrideContext: ${webExtContextData.overrideContext}`);
+  },
+
+  createAndInsertTopLevelElements(root, contextData, nextSibling) {
+    let rootElements;
+    if (contextData.onBrowserAction || contextData.onPageAction) {
+      if (contextData.extension.id !== root.extension.id) {
+        return;
+      }
+      rootElements = this.buildTopLevelElements(root, contextData, ACTION_MENU_TOP_LEVEL_LIMIT, false);
+
+      // Action menu items are prepended to the menu, followed by a separator.
+      nextSibling = nextSibling || this.xulMenu.firstElementChild;
+      if (rootElements.length && !this.itemsToCleanUp.has(nextSibling)) {
+        rootElements.push(this.xulMenu.ownerDocument.createXULElement("menuseparator"));
+      }
+    } else if (contextData.webExtContextData) {
+      let {
+        extensionId,
+        showDefaults,
+        overrideContext,
+      } = contextData.webExtContextData;
+      if (extensionId === root.extension.id) {
+        rootElements = this.buildTopLevelElements(root, contextData, Infinity, false);
+        // The extension menu should be rendered at the top, but after the navigation buttons.
+        nextSibling = nextSibling || this.xulMenu.querySelector(":scope > #context-sep-navigation + *");
+        if (rootElements.length && showDefaults && !this.itemsToCleanUp.has(nextSibling)) {
+          rootElements.push(this.xulMenu.ownerDocument.createXULElement("menuseparator"));
+        }
+      } else if (!showDefaults && !overrideContext) {
+        // When the default menu items should be hidden, menu items from other
+        // extensions should be hidden too.
+        return;
+      }
+      // Fall through to show default extension menu items.
+    }
+    if (!rootElements) {
+      rootElements = this.buildTopLevelElements(root, contextData, 1, true);
+      if (rootElements.length && !this.itemsToCleanUp.has(this.xulMenu.lastElementChild)) {
+        // All extension menu items are appended at the end.
+        // Prepend separator if this is the first extension menu item.
+        rootElements.unshift(this.xulMenu.ownerDocument.createXULElement("menuseparator"));
+      }
+    }
+
+    if (!rootElements.length) {
+      return;
+    }
+
+    if (nextSibling) {
+      nextSibling.before(...rootElements);
+    } else {
+      this.xulMenu.append(...rootElements);
+    }
+    for (let item of rootElements) {
+      this.itemsToCleanUp.add(item);
+    }
+  },
+
+  buildElementWithChildren(item, contextData) {
+    const element = this.buildSingleElement(item, contextData);
+    const children = this.buildChildren(item, contextData);
+    if (children.length) {
+      element.firstElementChild.append(...children);
+    }
+    return element;
+  },
+
+  buildChildren(item, contextData) {
+    let groupName;
+    let children = [];
+    for (let child of item.children) {
+      if (child.type == "radio" && !child.groupName) {
+        if (!groupName) {
+          groupName = `webext-radio-group-${gNextRadioGroupID++}`;
+        }
+        child.groupName = groupName;
+      } else {
+        groupName = null;
+      }
+
+      if (child.enabledForContext(contextData)) {
+        children.push(this.buildElementWithChildren(child, contextData));
+      }
+    }
+    return children;
+  },
+
+  buildTopLevelElements(root, contextData, maxCount, forceManifestIcons) {
+    let children = this.buildChildren(root, contextData);
+
+    // TODO: Fix bug 1492969 and remove this whole if block.
+    if (children.length === 1 && maxCount === 1 && forceManifestIcons &&
+        AppConstants.platform === "linux" &&
+        children[0].getAttribute("type") === "checkbox") {
+      // Keep single checkbox items in the submenu on Linux since
+      // the extension icon overlaps the checkbox otherwise.
+      maxCount = 0;
+    }
+
+    if (children.length > maxCount) {
+      // Move excess items into submenu.
+      let rootElement = this.buildSingleElement(root, contextData);
+      rootElement.setAttribute("ext-type", "top-level-menu");
+      rootElement.firstElementChild.append(...children.splice(maxCount - 1));
+      children.push(rootElement);
+    }
+
+    if (forceManifestIcons) {
+      for (let rootElement of children) {
+        // Display the extension icon on the root element.
+        if (root.extension.manifest.icons) {
+          this.setMenuItemIcon(rootElement, root.extension, contextData, root.extension.manifest.icons);
+        } else {
+          this.removeMenuItemIcon(rootElement);
+        }
+      }
+    }
+    return children;
+  },
+
+  removeSeparatorIfNoTopLevelItems() {
+    // Extension menu items always have have a non-empty ID.
+    let isNonExtensionSeparator =
+      item => item.nodeName === "menuseparator" && !item.id;
+
+    // itemsToCleanUp contains all top-level menu items. A separator should
+    // only be kept if it is next to an extension menu item.
+    let isExtensionMenuItemSibling =
+      item => item && this.itemsToCleanUp.has(item) && !isNonExtensionSeparator(item);
+
+    for (let item of this.itemsToCleanUp) {
+      if (isNonExtensionSeparator(item)) {
+        if (!isExtensionMenuItemSibling(item.previousElementSibling) &&
+            !isExtensionMenuItemSibling(item.nextElementSibling)) {
+          item.remove();
+          this.itemsToCleanUp.delete(item);
+        }
+      }
+    }
+  },
+
+  buildSingleElement(item, contextData) {
+    let doc = contextData.menu.ownerDocument;
+    let element;
+    if (item.children.length > 0) {
+      element = this.createMenuElement(doc, item);
+    } else if (item.type == "separator") {
+      element = doc.createXULElement("menuseparator");
+    } else {
+      element = doc.createXULElement("menuitem");
+    }
+
+    return this.customizeElement(element, item, contextData);
+  },
+
+  createMenuElement(doc, item) {
+    let element = doc.createXULElement("menu");
+    // Menu elements need to have a menupopup child for its menu items.
+    let menupopup = doc.createXULElement("menupopup");
+    element.appendChild(menupopup);
+    return element;
+  },
+
+  customizeElement(element, item, contextData) {
+    let label = item.title;
+    if (label) {
+      let accessKey;
+      label = label.replace(/&([\S\s]|$)/g, (_, nextChar, i) => {
+        if (nextChar === "&") {
+          return "&";
+        }
+        if (accessKey === undefined) {
+          if (nextChar === "%" && label.charAt(i + 2) === "s") {
+            accessKey = "";
+          } else {
+            accessKey = nextChar;
+          }
+        }
+        return nextChar;
+      });
+      element.setAttribute("accesskey", accessKey || "");
+
+      if (contextData.isTextSelected && label.indexOf("%s") > -1) {
+        let selection = contextData.selectionText.trim();
+        // The rendering engine will truncate the title if it's longer than 64 characters.
+        // But if it makes sense let's try truncate selection text only, to handle cases like
+        // 'look up "%s" in MyDictionary' more elegantly.
+
+        let codePointsToRemove = 0;
+
+        let selectionArray = Array.from(selection);
+
+        let completeLabelLength = label.length - 2 + selectionArray.length;
+        if (completeLabelLength > gMaxLabelLength) {
+          codePointsToRemove = completeLabelLength - gMaxLabelLength;
+        }
+
+        if (codePointsToRemove) {
+          let ellipsis = "\u2026";
+          try {
+            ellipsis = Services.prefs.getComplexValue("intl.ellipsis",
+                                                      Ci.nsIPrefLocalizedString).data;
+          } catch (e) { }
+          codePointsToRemove += 1;
+          selection = selectionArray.slice(0, -codePointsToRemove).join("") + ellipsis;
+        }
+
+        label = label.replace(/%s/g, selection);
+      }
+
+      element.setAttribute("label", label);
+    }
+
+    element.setAttribute("id", item.elementId);
+
+    if ("icons" in item) {
+      if (item.icons) {
+        this.setMenuItemIcon(element, item.extension, contextData, item.icons);
+      } else {
+        this.removeMenuItemIcon(element);
+      }
+    }
+
+    if (item.type == "checkbox") {
+      element.setAttribute("type", "checkbox");
+      if (item.checked) {
+        element.setAttribute("checked", "true");
+      }
+    } else if (item.type == "radio") {
+      element.setAttribute("type", "radio");
+      element.setAttribute("name", item.groupName);
+      if (item.checked) {
+        element.setAttribute("checked", "true");
+      }
+    }
+
+    if (!item.enabled) {
+      element.setAttribute("disabled", "true");
+    }
+
+    let button;
+
+    element.addEventListener("command", event => {
+      if (event.target !== event.currentTarget) {
+        return;
+      }
+      const wasChecked = item.checked;
+      if (item.type == "checkbox") {
+        item.checked = !item.checked;
+      } else if (item.type == "radio") {
+        // Deselect all radio items in the current radio group.
+        for (let child of item.parent.children) {
+          if (child.type == "radio" && child.groupName == item.groupName) {
+            child.checked = false;
+          }
+        }
+        // Select the clicked radio item.
+        item.checked = true;
+      }
+
+      let {webExtContextData} = contextData;
+      if (contextData.tab &&
+          // If the menu context was overridden by the extension, do not grant
+          // activeTab since the extension also controls the tabId.
+          (!webExtContextData || webExtContextData.extensionId !== item.extension.id)) {
+        item.tabManager.addActiveTabPermission(contextData.tab);
+      }
+
+      let info = item.getClickInfo(contextData, wasChecked);
+
+      const map = {shiftKey: "Shift", altKey: "Alt", metaKey: "Command", ctrlKey: "Ctrl"};
+      info.modifiers = Object.keys(map).filter(key => event[key]).map(key => map[key]);
+      if (event.ctrlKey && AppConstants.platform === "macosx") {
+        info.modifiers.push("MacCtrl");
+      }
+
+      info.button = button;
+
+      // Allow menus to open various actions supported in webext prior
+      // to notifying onclicked.
+      let actionFor = {
+        _execute_browser_action: global.browserActionFor,
+      }[item.command];
+      if (actionFor) {
+        let win = event.target.ownerGlobal;
+        actionFor(item.extension).triggerAction(win);
+      }
+
+      item.extension.emit("webext-menu-menuitem-click", info, contextData.tab);
+    }, {once: true});
+
+    element.addEventListener("click", event => { // eslint-disable-line mozilla/balanced-listeners
+      if (event.target !== event.currentTarget ||
+          // Ignore menu items that are usually not clickeable,
+          // such as separators and parents of submenus and disabled items.
+          element.localName !== "menuitem" ||
+          element.disabled) {
+        return;
+      }
+
+      button = event.button;
+      if (event.button) {
+        element.doCommand();
+        contextData.menu.hidePopup();
+      }
+    });
+
+    // Don't publish the ID of the root because the root element is
+    // auto-generated.
+    if (item.parent) {
+      gShownMenuItems.get(item.extension).push(item.id);
+    }
+
+    return element;
+  },
+
+  setMenuItemIcon(element, extension, contextData, icons) {
+    let parentWindow = contextData.menu.ownerGlobal;
+
+    let {icon} = IconDetails.getPreferredIcon(icons, extension,
+                                              16 * parentWindow.devicePixelRatio);
+
+    // The extension icons in the manifest are not pre-resolved, since
+    // they're sometimes used by the add-on manager when the extension is
+    // not enabled, and its URLs are not resolvable.
+    let resolvedURL = extension.baseURI.resolve(icon);
+
+    if (element.localName == "menu") {
+      element.setAttribute("class", "menu-iconic");
+    } else if (element.localName == "menuitem") {
+      element.setAttribute("class", "menuitem-iconic");
+    }
+
+    element.setAttribute("image", resolvedURL);
+  },
+
+  // Undo changes from setMenuItemIcon.
+  removeMenuItemIcon(element) {
+    element.removeAttribute("class");
+    element.removeAttribute("image");
+  },
+
+  rebuildMenu(extension) {
+    let {contextData} = this;
+    if (!contextData) {
+      // This happens if the menu is not visible.
+      return;
+    }
+
+    // Find the group of existing top-level items (usually 0 or 1 items)
+    // and remember its position for when the new items are inserted.
+    let elementIdPrefix = `${makeWidgetId(extension.id)}-menuitem-`;
+    let nextSibling = null;
+    for (let item of this.itemsToCleanUp) {
+      if (item.id && item.id.startsWith(elementIdPrefix)) {
+        nextSibling = item.nextSibling;
+        item.remove();
+        this.itemsToCleanUp.delete(item);
+      }
+    }
+
+    let root = gRootItems.get(extension);
+    if (root) {
+      this.createAndInsertTopLevelElements(root, contextData, nextSibling);
+    }
+    this.removeSeparatorIfNoTopLevelItems();
+  },
+
+  // This should be called once, after constructing the top-level menus, if any.
+  afterBuildingMenu(contextData) {
+    function dispatchOnShownEvent(extension) {
+      // Note: gShownMenuItems is a DefaultMap, so .get(extension) causes the
+      // extension to be stored in the map even if there are currently no
+      // shown menu items. This ensures that the onHidden event can be fired
+      // when the menu is closed.
+      let menuIds = gShownMenuItems.get(extension);
+      extension.emit("webext-menu-shown", menuIds, contextData);
+    }
+
+    if (contextData.onBrowserAction || contextData.onPageAction) {
+      dispatchOnShownEvent(contextData.extension);
+    } else {
+      gOnShownSubscribers.forEach(dispatchOnShownEvent);
+    }
+
+    this.contextData = contextData;
+  },
+
+  hideDefaultMenuItems() {
+    for (let item of this.xulMenu.children) {
+      if (!this.itemsToCleanUp.has(item)) {
+        item.hidden = true;
+      }
+    }
+  },
+
+  handleEvent(event) {
+    if (this.xulMenu != event.target || event.type != "popuphidden") {
+      return;
+    }
+
+    delete this.xulMenu;
+    delete this.contextData;
+
+    let target = event.target;
+    target.removeEventListener("popuphidden", this);
+    for (let item of this.itemsToCleanUp) {
+      item.remove();
+    }
+    this.itemsToCleanUp.clear();
+    for (let extension of gShownMenuItems.keys()) {
+      extension.emit("webext-menu-hidden");
+    }
+    gShownMenuItems.clear();
+  },
+
+  itemsToCleanUp: new Set(),
+};
+
+// Called from pageAction or browserAction popup.
+global.actionContextMenu = function(contextData) {
+  contextData.tab = tabTracker.activeTab;
+  contextData.pageUrl = contextData.tab.linkedBrowser.currentURI.spec;
+  gMenuBuilder.build(contextData);
+};
+
+const contextsMap = {
+  onAudio: "audio",
+  onEditable: "editable",
+  inFrame: "frame",
+  onImage: "image",
+  onLink: "link",
+  onPassword: "password",
+  isTextSelected: "selection",
+  onVideo: "video",
+
+  onBrowserAction: "browser_action",
+  onTab: "tab",
+
+  selectedMessages: "message_list",
+  selectedFolder: "folder_pane",
+};
+
+const getMenuContexts = contextData => {
+  let contexts = new Set();
+
+  for (const [key, value] of Object.entries(contextsMap)) {
+    if (contextData[key]) {
+      contexts.add(value);
+    }
+  }
+
+  if (contexts.size === 0) {
+    contexts.add("page");
+  }
+
+  // New non-content contexts supported in Firefox are not part of "all".
+  if (!contextData.onTab) {
+    contexts.add("all");
+  }
+
+  return contexts;
+};
+
+function getContextViewType(contextData) {
+  if ("originalViewType" in contextData) {
+    return contextData.originalViewType;
+  }
+  if (contextData.webExtBrowserType === "popup" ||
+      contextData.webExtBrowserType === "sidebar") {
+    return contextData.webExtBrowserType;
+  }
+  if (contextData.tab && contextData.menu.id === "tabContextMenu") {
+    return "tab";
+  }
+  return undefined;
+}
+
+function addMenuEventInfo(info, contextData, extension, includeSensitiveData) {
+  info.viewType = getContextViewType(contextData);
+  if (contextData.onVideo) {
+    info.mediaType = "video";
+  } else if (contextData.onAudio) {
+    info.mediaType = "audio";
+  } else if (contextData.onImage) {
+    info.mediaType = "image";
+  }
+  if (contextData.frameId !== undefined) {
+    info.frameId = contextData.frameId;
+  }
+  info.editable = contextData.onEditable || false;
+  if (includeSensitiveData) {
+    if (contextData.timeStamp) {
+      // Convert to integer, in case the DOMHighResTimeStamp has a fractional part.
+      info.targetElementId = Math.floor(contextData.timeStamp);
+    }
+    if (contextData.onLink) {
+      info.linkText = contextData.linkText;
+      info.linkUrl = contextData.linkUrl;
+    }
+    if (contextData.onAudio || contextData.onImage || contextData.onVideo) {
+      info.srcUrl = contextData.srcUrl;
+    }
+    if (contextData.inFrame) {
+      info.frameUrl = contextData.frameUrl;
+    }
+    if (contextData.isTextSelected) {
+      info.selectionText = contextData.selectionText;
+    }
+  }
+  // If the context was overridden, then frameUrl should be the URL of the
+  // document in which the menu was opened (instead of undefined, even if that
+  // document is not in a frame).
+  if (contextData.originalViewUrl) {
+    info.frameUrl = contextData.originalViewUrl;
+  }
+
+  if (contextData.selectedMessages && extension.hasPermission("messagesRead")) {
+    info.selectedMessages = messageListTracker.startList(contextData.selectedMessages, {extension});
+  }
+  for (let folderType of ["displayedFolder", "selectedFolder"]) {
+    if (contextData[folderType] && extension.hasPermission("accountsRead")) {
+      info[folderType] = convertFolder(contextData[folderType]);
+    }
+  }
+}
+
+function MenuItem(extension, createProperties, isRoot = false) {
+  this.extension = extension;
+  this.children = [];
+  this.parent = null;
+  this.tabManager = extension.tabManager;
+
+  this.setDefaults();
+  this.setProps(createProperties);
+
+  if (!this.hasOwnProperty("_id")) {
+    this.id = gNextMenuItemID++;
+  }
+  // If the item is not the root and has no parent
+  // it must be a child of the root.
+  if (!isRoot && !this.parent) {
+    this.root.addChild(this);
+  }
+}
+
+MenuItem.prototype = {
+  setProps(createProperties) {
+    for (let propName in createProperties) {
+      if (createProperties[propName] === null) {
+        // Omitted optional argument.
+        continue;
+      }
+      this[propName] = createProperties[propName];
+    }
+
+    if ("icons" in createProperties && createProperties.icons === null) {
+      this.icons = null;
+    }
+
+    if (createProperties.documentUrlPatterns != null) {
+      this.documentUrlMatchPattern = new MatchPatternSet(this.documentUrlPatterns, {
+        restrictSchemes: this.extension.restrictSchemes,
+      });
+    }
+
+    if (createProperties.targetUrlPatterns != null) {
+      this.targetUrlMatchPattern = new MatchPatternSet(this.targetUrlPatterns, {
+        // restrictSchemes default to false when matching links instead of pages
+        // (see Bug 1280370 for a rationale).
+        restrictSchemes: false,
+      });
+    }
+
+    // If a child MenuItem does not specify any contexts, then it should
+    // inherit the contexts specified from its parent.
+    if (createProperties.parentId && !createProperties.contexts) {
+      this.contexts = this.parent.contexts;
+    }
+  },
+
+  setDefaults() {
+    this.setProps({
+      type: "normal",
+      checked: false,
+      contexts: ["all"],
+      enabled: true,
+      visible: true,
+    });
+  },
+
+  set id(id) {
+    if (this.hasOwnProperty("_id")) {
+      throw new ExtensionError("ID of a MenuItem cannot be changed");
+    }
+    let isIdUsed = gMenuMap.get(this.extension).has(id);
+    if (isIdUsed) {
+      throw new ExtensionError(`ID already exists: ${id}`);
+    }
+    this._id = id;
+  },
+
+  get id() {
+    return this._id;
+  },
+
+  get elementId() {
+    let id = this.id;
+    // If the ID is an integer, it is auto-generated and globally unique.
+    // If the ID is a string, it is only unique within one extension and the
+    // ID needs to be concatenated with the extension ID.
+    if (typeof id !== "number") {
+      // To avoid collisions with numeric IDs, add a prefix to string IDs.
+      id = `_${id}`;
+    }
+    return `${makeWidgetId(this.extension.id)}-menuitem-${id}`;
+  },
+
+  ensureValidParentId(parentId) {
+    if (parentId === undefined) {
+      return;
+    }
+    let menuMap = gMenuMap.get(this.extension);
+    if (!menuMap.has(parentId)) {
+      throw new ExtensionError(`Could not find any MenuItem with id: ${parentId}`);
+    }
+    for (let item = menuMap.get(parentId); item; item = item.parent) {
+      if (item === this) {
+        throw new ExtensionError("MenuItem cannot be an ancestor (or self) of its new parent.");
+      }
+    }
+  },
+
+  set parentId(parentId) {
+    this.ensureValidParentId(parentId);
+
+    if (this.parent) {
+      this.parent.detachChild(this);
+    }
+
+    if (parentId === undefined) {
+      this.root.addChild(this);
+    } else {
+      let menuMap = gMenuMap.get(this.extension);
+      menuMap.get(parentId).addChild(this);
+    }
+  },
+
+  get parentId() {
+    return this.parent ? this.parent.id : undefined;
+  },
+
+  addChild(child) {
+    if (child.parent) {
+      throw new Error("Child MenuItem already has a parent.");
+    }
+    this.children.push(child);
+    child.parent = this;
+  },
+
+  detachChild(child) {
+    let idx = this.children.indexOf(child);
+    if (idx < 0) {
+      throw new Error("Child MenuItem not found, it cannot be removed.");
+    }
+    this.children.splice(idx, 1);
+    child.parent = null;
+  },
+
+  get root() {
+    let extension = this.extension;
+    if (!gRootItems.has(extension)) {
+      let root = new MenuItem(extension,
+                              {title: extension.name},
+                              /* isRoot = */ true);
+      gRootItems.set(extension, root);
+    }
+
+    return gRootItems.get(extension);
+  },
+
+  remove() {
+    if (this.parent) {
+      this.parent.detachChild(this);
+    }
+    let children = this.children.slice(0);
+    for (let child of children) {
+      child.remove();
+    }
+
+    let menuMap = gMenuMap.get(this.extension);
+    menuMap.delete(this.id);
+    if (this.root == this) {
+      gRootItems.delete(this.extension);
+    }
+  },
+
+  getClickInfo(contextData, wasChecked) {
+    let info = {
+      menuItemId: this.id,
+    };
+    if (this.parent) {
+      info.parentMenuItemId = this.parentId;
+    }
+
+    addMenuEventInfo(info, contextData, this.extension, true);
+
+    if ((this.type === "checkbox") || (this.type === "radio")) {
+      info.checked = this.checked;
+      info.wasChecked = wasChecked;
+    }
+
+    return info;
+  },
+
+  enabledForContext(contextData) {
+    if (!this.visible) {
+      return false;
+    }
+    let contexts = getMenuContexts(contextData);
+    if (!this.contexts.some(n => contexts.has(n))) {
+      return false;
+    }
+
+    if (this.viewTypes && !this.viewTypes.includes(getContextViewType(contextData))) {
+      return false;
+    }
+
+    let docPattern = this.documentUrlMatchPattern;
+    // When viewTypes is specified, the menu item is expected to be restricted
+    // to documents. So let documentUrlPatterns always apply to the URL of the
+    // document in which the menu was opened. When maybeOverrideContextData
+    // changes the context, contextData.pageUrl does not reflect that URL any
+    // more, so use contextData.originalViewUrl instead.
+    if (docPattern && this.viewTypes && contextData.originalViewUrl) {
+      if (!docPattern.matches(Services.io.newURI(contextData.originalViewUrl))) {
+        return false;
+      }
+      docPattern = null; // Null it so that it won't be used with pageURI below.
+    }
+
+    let pageURI = contextData[contextData.inFrame ? "frameUrl" : "pageUrl"];
+    if (pageURI) {
+      pageURI = Services.io.newURI(pageURI);
+      if (docPattern && !docPattern.matches(pageURI)) {
+        return false;
+      }
+    }
+
+    let targetPattern = this.targetUrlMatchPattern;
+    if (targetPattern) {
+      let targetUrls = [];
+      if (contextData.onImage || contextData.onAudio || contextData.onVideo) {
+        // TODO: double check if srcUrl is always set when we need it
+        targetUrls.push(contextData.srcUrl);
+      }
+      if (contextData.onLink) {
+        targetUrls.push(contextData.linkUrl);
+      }
+      if (!targetUrls.some(targetUrl => targetPattern.matches(Services.io.newURI(targetUrl)))) {
+        return false;
+      }
+    }
+
+    return true;
+  },
+};
+
+// While any extensions are active, this Tracker registers to observe/listen
+// for menu events from both Tools and context menus, both content and chrome.
+const menuTracker = {
+  menuIds: ["tabContextMenu", "folderPaneContext"],
+
+  register() {
+    Services.obs.addObserver(this, "on-build-contextmenu");
+    for (const window of windowTracker.browserWindows()) {
+      this.onWindowOpen(window);
+    }
+    windowTracker.addOpenListener(this.onWindowOpen);
+  },
+
+  unregister() {
+    Services.obs.removeObserver(this, "on-build-contextmenu");
+    for (const window of windowTracker.browserWindows()) {
+      for (const id of this.menuIds) {
+        const menu = window.document.getElementById(id);
+        menu.removeEventListener("popupshowing", this);
+      }
+    }
+    windowTracker.removeOpenListener(this.onWindowOpen);
+  },
+
+  observe(subject, topic, data) {
+    subject = subject.wrappedJSObject;
+    gMenuBuilder.build(subject);
+  },
+
+  onWindowOpen(window) {
+    for (const id of menuTracker.menuIds) {
+      const menu = window.document.getElementById(id);
+      menu.addEventListener("popupshowing", menuTracker);
+    }
+  },
+
+  handleEvent(event) {
+    const menu = event.target;
+    if (menu.id === "tabContextMenu") {
+      const trigger = menu.triggerNode;
+      const tab = trigger.localName === "tab" ? trigger : tabTracker.activeTab;
+      const pageUrl = tab.linkedBrowser.currentURI.spec;
+      gMenuBuilder.build({menu, tab, pageUrl, onTab: true});
+    }
+    if (menu.id === "folderPaneContext") {
+      const trigger = menu.triggerNode;
+      const tab = trigger.localName === "tab" ? trigger : tabTracker.activeTab;
+      const pageUrl = tab.linkedBrowser.currentURI.spec;
+      gMenuBuilder.build({
+        menu, tab, pageUrl,
+        selectedFolder: trigger.ownerGlobal.gFolderTreeView.getSelectedFolders()[0],
+      });
+    }
+  },
+};
+
+this.menus = class extends ExtensionAPI {
+  constructor(extension) {
+    super(extension);
+
+    if (!gMenuMap.size) {
+      menuTracker.register();
+    }
+    gMenuMap.set(extension, new Map());
+  }
+
+  onShutdown(reason) {
+    let {extension} = this;
+
+    if (gMenuMap.has(extension)) {
+      gMenuMap.delete(extension);
+      gRootItems.delete(extension);
+      gShownMenuItems.delete(extension);
+      gOnShownSubscribers.delete(extension);
+      if (!gMenuMap.size) {
+        menuTracker.unregister();
+      }
+    }
+  }
+
+  getAPI(context) {
+    let {extension} = context;
+
+    return {
+      menus: {
+        refresh() {
+          gMenuBuilder.rebuildMenu(extension);
+        },
+
+        onShown: new EventManager({
+          context,
+          name: "menus.onShown",
+          register: fire => {
+            let listener = (event, menuIds, contextData) => {
+              let info = {
+                menuIds,
+                contexts: Array.from(getMenuContexts(contextData)),
+              };
+
+              let nativeTab = contextData.tab;
+
+              // The menus.onShown event is fired before the user has consciously
+              // interacted with an extension, so we require permissions before
+              // exposing sensitive contextual data.
+              let contextUrl = contextData.inFrame ? contextData.frameUrl : contextData.pageUrl;
+              let includeSensitiveData =
+                (nativeTab && extension.tabManager.hasActiveTabPermission(nativeTab)) ||
+                (contextUrl && extension.whiteListedHosts.matches(contextUrl));
+
+              addMenuEventInfo(info, contextData, extension, includeSensitiveData);
+
+              let tab = nativeTab && extension.tabManager.convert(nativeTab);
+              fire.sync(info, tab);
+            };
+            gOnShownSubscribers.add(extension);
+            extension.on("webext-menu-shown", listener);
+            return () => {
+              gOnShownSubscribers.delete(extension);
+              extension.off("webext-menu-shown", listener);
+            };
+          },
+        }).api(),
+        onHidden: new EventManager({
+          context,
+          name: "menus.onHidden",
+          register: fire => {
+            let listener = () => {
+              fire.sync();
+            };
+            extension.on("webext-menu-hidden", listener);
+            return () => {
+              extension.off("webext-menu-hidden", listener);
+            };
+          },
+        }).api(),
+
+        create(createProperties) {
+          // Note that the id is required by the schema. If the addon did not set
+          // it, the implementation of menus.create in the child should
+          // have added it.
+          let menuItem = new MenuItem(extension, createProperties);
+          gMenuMap.get(extension).set(menuItem.id, menuItem);
+        },
+
+        update(id, updateProperties) {
+          let menuItem = gMenuMap.get(extension).get(id);
+          if (menuItem) {
+            menuItem.setProps(updateProperties);
+          }
+        },
+
+        remove(id) {
+          let menuItem = gMenuMap.get(extension).get(id);
+          if (menuItem) {
+            menuItem.remove();
+          }
+        },
+
+        removeAll() {
+          let root = gRootItems.get(extension);
+          if (root) {
+            root.remove();
+          }
+        },
+
+        onClicked: new EventManager({
+          context,
+          name: "menus.onClicked",
+          register: fire => {
+            let listener = (event, info, nativeTab) => {
+              let {linkedBrowser} = nativeTab || tabTracker.activeTab;
+              let tab = nativeTab && extension.tabManager.convert(nativeTab);
+              context.withPendingBrowser(linkedBrowser,
+                                         () => fire.sync(info, tab));
+            };
+
+            extension.on("webext-menu-menuitem-click", listener);
+            return () => {
+              extension.off("webext-menu-menuitem-click", listener);
+            };
+          },
+        }).api(),
+      },
+    };
+  }
+};
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/schemas/menus.json
@@ -0,0 +1,577 @@
+// Copyright (c) 2012 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+[
+  {
+    "namespace": "manifest",
+    "types": [
+      {
+        "$extend": "Permission",
+        "choices": [{
+          "type": "string",
+          "enum": [
+            "menus"
+          ]
+        }]
+      }, {
+        "$extend": "OptionalPermission",
+        "choices": [{
+          "type": "string",
+          "enum": [
+            "menus.overrideContext"
+          ]
+        }]
+      }
+    ]
+  },
+  {
+    "namespace": "menus",
+    "permissions": ["menus"],
+    "description": "Use the browser.menus API to add items to the browser's menus. You can choose what types of objects your context menu additions apply to, such as images, hyperlinks, and pages.",
+    "properties": {
+      "ACTION_MENU_TOP_LEVEL_LIMIT": {
+        "value": 6,
+        "description": "The maximum number of top level extension items that can be added to an extension action context menu. Any items beyond this limit will be ignored."
+      }
+    },
+    "types": [
+      {
+        "id": "ContextType",
+        "type": "string",
+        "enum": ["all", "page", "frame", "selection", "link", "editable", "password", "image", "video", "audio", "browser_action", "tab", "message_list", "folder_pane"],
+        "description": "The different contexts a menu can appear in. Specifying 'all' is equivalent to the combination of all other contexts except for 'tab'."
+      },
+      {
+        "id": "ItemType",
+        "type": "string",
+        "enum": ["normal", "checkbox", "radio", "separator"],
+        "description": "The type of menu item."
+      },
+      {
+        "id": "OnClickData",
+        "type": "object",
+        "description": "Information sent when a context menu item is clicked.",
+        "properties": {
+          "menuItemId": {
+            "choices": [
+              { "type": "integer" },
+              { "type": "string" }
+            ],
+            "description": "The ID of the menu item that was clicked."
+          },
+          "parentMenuItemId": {
+            "choices": [
+              { "type": "integer" },
+              { "type": "string" }
+            ],
+            "optional": true,
+            "description": "The parent ID, if any, for the item clicked."
+          },
+          "viewType": {
+            "$ref": "extension.ViewType",
+            "optional": true,
+            "description": "The type of view where the menu is clicked. May be unset if the menu is not associated with a view."
+          },
+          "mediaType": {
+            "type": "string",
+            "optional": true,
+            "description": "One of 'image', 'video', or 'audio' if the context menu was activated on one of these types of elements."
+          },
+          "linkText": {
+            "type": "string",
+            "optional": true,
+            "description": "If the element is a link, the text of that link."
+          },
+          "linkUrl": {
+            "type": "string",
+            "optional": true,
+            "description": "If the element is a link, the URL it points to."
+          },
+          "srcUrl": {
+            "type": "string",
+            "optional": true,
+            "description": "Will be present for elements with a 'src' URL."
+          },
+          "pageUrl": {
+            "type": "string",
+            "optional": true,
+            "description": "The URL of the page where the menu item was clicked. This property is not set if the click occured in a context where there is no current page, such as in a launcher context menu."
+          },
+          "frameId": {
+           "type": "integer",
+           "optional": true,
+           "minimum": 0,
+           "description": "The id of the frame of the element where the context menu was clicked."
+          },
+          "frameUrl": {
+            "type": "string",
+            "optional": true,
+            "description": " The URL of the frame of the element where the context menu was clicked, if it was in a frame."
+          },
+          "selectionText": {
+            "type": "string",
+            "optional": true,
+            "description": "The text for the context selection, if any."
+          },
+          "editable": {
+            "type": "boolean",
+            "description": "A flag indicating whether the element is editable (text input, textarea, etc.)."
+          },
+          "wasChecked": {
+            "type": "boolean",
+            "optional": true,
+            "description": "A flag indicating the state of a checkbox or radio item before it was clicked."
+          },
+          "checked": {
+            "type": "boolean",
+            "optional": true,
+            "description": "A flag indicating the state of a checkbox or radio item after it is clicked."
+          },
+          "modifiers": {
+            "type": "array",
+            "items": {
+              "type": "string",
+              "enum": ["Shift", "Alt", "Command", "Ctrl", "MacCtrl"]
+            },
+            "description": "An array of keyboard modifiers that were held while the menu item was clicked."
+          },
+          "button": {
+            "type": "integer",
+            "optional": true,
+            "description": "An integer value of button by which menu item was clicked."
+          },
+          "targetElementId": {
+            "type": "integer",
+            "optional": true,
+            "description": "An identifier of the clicked element, if any. Use menus.getTargetElement in the page to find the corresponding element."
+          },
+          "selectedMessages": {
+            "$ref": "messages.MessageList",
+            "optional": true,
+            "description": "The selected messages, if the context menu was opened in the message list."
+          },
+          "displayedFolder": {
+            "$ref": "accounts.MailFolder",
+            "optional": true,
+            "description": "The displayed folder, if the context menu was opened in the message list."
+          },
+          "selectedFolder": {
+            "$ref": "accounts.MailFolder",
+            "optional": true,
+            "description": "The selected folder, if the context menu was opened in the folder pane."
+          }
+        }
+      }
+    ],
+    "functions": [
+      {
+        "name": "create",
+        "type": "function",
+        "description": "Creates a new context menu item. Note that if an error occurs during creation, you may not find out until the creation callback fires (the details will be in $(ref:runtime.lastError)).",
+        "returns": {
+          "choices": [
+            { "type": "integer" },
+            { "type": "string" }
+          ],
+          "description": "The ID of the newly created item."
+        },
+        "parameters": [
+          {
+            "type": "object",
+            "name": "createProperties",
+            "properties": {
+              "type": {
+                "$ref": "ItemType",
+                "optional": true,
+                "description": "The type of menu item. Defaults to 'normal' if not specified."
+              },
+              "id": {
+                "type": "string",
+                "optional": true,
+                "description": "The unique ID to assign to this item. Mandatory for event pages. Cannot be the same as another ID for this extension."
+              },
+              "icons": {
+                "type": "object",
+                "optional" : true,
+                "patternProperties" : {
+                  "^[1-9]\\d*$": { "type" : "string" }
+                }
+              },
+              "title": {
+                "type": "string",
+                "optional": true,
+                "description": "The text to be displayed in the item; this is <em>required</em> unless <code>type</code> is 'separator'. When the context is 'selection', you can use <code>%s</code> within the string to show the selected text. For example, if this parameter's value is \"Translate '%s' to Pig Latin\" and the user selects the word \"cool\", the context menu item for the selection is \"Translate 'cool' to Pig Latin\"."
+              },
+              "checked": {
+                "type": "boolean",
+                "optional": true,
+                "description": "The initial state of a checkbox or radio item: true for selected and false for unselected. Only one radio item can be selected at a time in a given group of radio items."
+              },
+              "contexts": {
+                "type": "array",
+                "items": {
+                  "$ref": "ContextType"
+                },
+                "minItems": 1,
+                "optional": true,
+                "description": "List of contexts this menu item will appear in. Defaults to ['page'] if not specified."
+              },
+              "viewTypes": {
+                "type": "array",
+                "items": {
+                  "$ref": "extension.ViewType"
+                },
+                "minItems": 1,
+                "optional": true,
+                "description": "List of view types where the menu item will be shown. Defaults to any view, including those without a viewType."
+              },
+              "visible": {
+                "type": "boolean",
+                "optional": true,
+                "description": "Whether the item is visible in the menu."
+              },
+              "onclick": {
+                "type": "function",
+                "optional": true,
+                "description": "A function that will be called back when the menu item is clicked. Event pages cannot use this.",
+                "parameters": [
+                  {
+                    "name": "info",
+                    "$ref": "OnClickData",
+                    "description": "Information about the item clicked and the context where the click happened."
+                  },
+                  {
+                    "name": "tab",
+                    "$ref": "tabs.Tab",
+                    "description": "The details of the tab where the click took place. Note: this parameter only present for extensions."
+                  }
+                ]
+              },
+              "parentId": {
+                "choices": [
+                  { "type": "integer" },
+                  { "type": "string" }
+                ],
+                "optional": true,
+                "description": "The ID of a parent menu item; this makes the item a child of a previously added item."
+              },
+              "documentUrlPatterns": {
+                "type": "array",
+                "items": {"type": "string"},
+                "optional": true,
+                "description": "Lets you restrict the item to apply only to documents whose URL matches one of the given patterns. (This applies to frames as well.) For details on the format of a pattern, see $(topic:match_patterns)[Match Patterns]."
+              },
+              "targetUrlPatterns": {
+                "type": "array",
+                "items": {"type": "string"},
+                "optional": true,
+                "description": "Similar to documentUrlPatterns, but lets you filter based on the src attribute of img/audio/video tags and the href of anchor tags."
+              },
+              "enabled": {
+                "type": "boolean",
+                "optional": true,
+                "description": "Whether this context menu item is enabled or disabled. Defaults to true."
+              },
+              "command": {
+                "type": "string",
+                "optional": true,
+                "description": "Specifies a command to issue for the context click.  Currently supports internal command _execute_browser_action."
+              }
+            }
+          },
+          {
+            "type": "function",
+            "name": "callback",
+            "optional": true,
+            "description": "Called when the item has been created in the browser. If there were any problems creating the item, details will be available in $(ref:runtime.lastError).",
+            "parameters": []
+          }
+        ]
+      },
+      {
+        "name": "update",
+        "type": "function",
+        "description": "Updates a previously created context menu item.",
+        "async": "callback",
+        "parameters": [
+          {
+            "choices": [
+              { "type": "integer" },
+              { "type": "string" }
+            ],
+            "name": "id",
+            "description": "The ID of the item to update."
+          },
+          {
+            "type": "object",
+            "name": "updateProperties",
+            "description": "The properties to update. Accepts the same values as the create function.",
+            "properties": {
+              "type": {
+                "$ref": "ItemType",
+                "optional": true
+              },
+              "icons": {
+                "type": "object",
+                "optional": "omit-key-if-missing",
+                "patternProperties" : {
+                  "^[1-9]\\d*$": { "type" : "string" }
+                }
+              },
+              "title": {
+                "type": "string",
+                "optional": true
+              },
+              "checked": {
+                "type": "boolean",
+                "optional": true
+              },
+              "contexts": {
+                "type": "array",
+                "items": {
+                  "$ref": "ContextType"
+                },
+                "minItems": 1,
+                "optional": true
+              },
+              "viewTypes": {
+                "type": "array",
+                "items": {
+                  "$ref": "extension.ViewType"
+                },
+                "minItems": 1,
+                "optional": true
+              },
+              "visible": {
+                "type": "boolean",
+                "optional": true,
+                "description": "Whether the item is visible in the menu."
+              },
+              "onclick": {
+                "type": "function",
+                "optional": "omit-key-if-missing",
+                "parameters": [
+                  {
+                    "name": "info",
+                    "$ref": "OnClickData"
+                  },
+                  {
+                    "name": "tab",
+                    "$ref": "tabs.Tab",
+                    "description": "The details of the tab where the click took place. Note: this parameter only present for extensions."
+                  }
+                ]
+              },
+              "parentId": {
+                "choices": [
+                  { "type": "integer" },
+                  { "type": "string" }
+                ],
+                "optional": true,
+                "description": "Note: You cannot change an item to be a child of one of its own descendants."
+              },
+              "documentUrlPatterns": {
+                "type": "array",
+                "items": {"type": "string"},
+                "optional": true
+              },
+              "targetUrlPatterns": {
+                "type": "array",
+                "items": {"type": "string"},
+                "optional": true
+              },
+              "enabled": {
+                "type": "boolean",
+                "optional": true
+              }
+            }
+          },
+          {
+            "type": "function",
+            "name": "callback",
+            "optional": true,
+            "parameters": [],
+            "description": "Called when the context menu has been updated."
+          }
+        ]
+      },
+      {
+        "name": "remove",
+        "type": "function",
+        "description": "Removes a context menu item.",
+        "async": "callback",
+        "parameters": [
+          {
+            "choices": [
+              { "type": "integer" },
+              { "type": "string" }
+            ],
+            "name": "menuItemId",
+            "description": "The ID of the context menu item to remove."
+          },
+          {
+            "type": "function",
+            "name": "callback",
+            "optional": true,
+            "parameters": [],
+            "description": "Called when the context menu has been removed."
+          }
+        ]
+      },
+      {
+        "name": "removeAll",
+        "type": "function",
+        "description": "Removes all context menu items added by this extension.",
+        "async": "callback",
+        "parameters": [
+          {
+            "type": "function",
+            "name": "callback",
+            "optional": true,
+            "parameters": [],
+            "description": "Called when removal is complete."
+          }
+        ]
+      },
+      {
+        "name": "overrideContext",
+        "permissions": ["menus.overrideContext"],
+        "type": "function",
+        "description": "Show the matching menu items from this extension instead of the default menu. This should be called during a 'contextmenu' DOM event handler, and only applies to the menu that opens after this event.",
+        "parameters": [
+          {
+            "name": "contextOptions",
+            "type": "object",
+            "properties": {
+              "showDefaults": {
+                "type": "boolean",
+                "optional": true,
+                "default": false,
+                "description": "Whether to also include default menu items in the menu."
+              },
+              "context": {
+                "type": "string",
+                "enum": ["tab"],
+                "optional": true,
+                "description": "ContextType to override, to allow menu items from other extensions in the menu. Currently only 'tab' is supported. showDefaults cannot be used with this option."
+              },
+              "tabId": {
+                "type": "integer",
+                "minimum": 0,
+                "optional": true,
+                "description": "Required when context is 'tab'. Requires 'tabs' permission."
+              }
+            }
+          }
+        ]
+      },
+      {
+        "name": "refresh",
+        "type": "function",
+        "description": "Updates the extension items in the shown menu, including changes that have been made since the menu was shown. Has no effect if the menu is hidden. Rebuilding a shown menu is an expensive operation, only invoke this method when necessary.",
+        "async": true,
+        "parameters": []
+      }
+    ],
+    "events": [
+      {
+        "name": "onClicked",
+        "type": "function",
+        "description": "Fired when a context menu item is clicked.",
+        "parameters": [
+          {
+            "name": "info",
+            "$ref": "OnClickData",
+            "description": "Information about the item clicked and the context where the click happened."
+          },
+          {
+            "name": "tab",
+            "$ref": "tabs.Tab",
+            "description": "The details of the tab where the click took place. If the click did not take place in a tab, this parameter will be missing.",
+            "optional": true
+          }
+        ]
+      },
+      {
+        "name": "onShown",
+        "type": "function",
+        "description": "Fired when a menu is shown. The extension can add, modify or remove menu items and call menus.refresh() to update the menu.",
+        "parameters": [
+          {
+            "name": "info",
+            "type": "object",
+            "description": "Information about the context of the menu action and the created menu items. For more information about each property, see OnClickData. The following properties are only set if the extension has host permissions for the given context: linkUrl, linkText, srcUrl, pageUrl, frameUrl, selectionText.",
+            "properties": {
+              "menuIds": {
+                "description": "A list of IDs of the menu items that were shown.",
+                "type": "array",
+                "items": {
+                  "choices": [
+                    { "type": "integer" },
+                    { "type": "string" }
+                  ]
+                }
+              },
+              "contexts": {
+                "description": "A list of all contexts that apply to the menu.",
+                "type": "array",
+                "items": {"$ref": "ContextType"}
+              },
+              "viewType": {
+                "$ref": "extension.ViewType",
+                "optional": true
+              },
+              "editable": {
+                "type": "boolean"
+              },
+              "mediaType": {
+                "type": "string",
+                "optional": true
+              },
+              "linkUrl": {
+                "type": "string",
+                "optional": true
+              },
+              "linkText": {
+                "type": "string",
+                "optional": true
+              },
+              "srcUrl": {
+                "type": "string",
+                "optional": true
+              },
+              "pageUrl": {
+                "type": "string",
+                "optional": true
+              },
+              "frameUrl": {
+                "type": "string",
+                "optional": true
+              },
+              "selectionText": {
+                "type": "string",
+                "optional": true
+              },
+              "targetElementId": {
+                "type": "integer",
+                "optional": true
+              }
+            }
+          },
+          {
+            "name": "tab",
+            "$ref": "tabs.Tab",
+            "description": "The details of the tab where the menu was opened."
+          }
+        ]
+      },
+      {
+        "name": "onHidden",
+        "type": "function",
+        "description": "Fired when a menu is hidden. This event is only fired if onShown has fired before.",
+        "parameters": []
+      }
+    ]
+  }
+]
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/schemas/menus_child.json
@@ -0,0 +1,29 @@
+[
+  {
+    "namespace": "menus",
+    "permissions": ["menus"],
+    "allowedContexts": ["content", "devtools"],
+    "description": "The part of the menus API that is available in all extension contexts, including content scripts.",
+    "functions": [
+      {
+        "name": "getTargetElement",
+        "type": "function",
+        "allowedContexts": ["content", "devtools"],
+        "description": "Retrieve the element that was associated with a recent contextmenu event.",
+        "parameters": [
+          {
+            "type": "integer",
+            "description": "The identifier of the clicked element, available as info.targetElementId in the menus.onShown, onClicked or onclick event.",
+            "name": "targetElementId"
+          }
+        ],
+        "returns": {
+          "type": "object",
+          "optional": true,
+          "isInstanceOf": "Element",
+          "additionalProperties": { "type": "any" }
+        }
+      }
+    ]
+  }
+]
--- a/mail/components/extensions/test/browser/browser.ini
+++ b/mail/components/extensions/test/browser/browser.ini
@@ -1,10 +1,11 @@
 [DEFAULT]
 head = head.js
 subsuite = thunderbird
 tags = webextensions
 
 [browser_ext_addressBooksUI.js]
 [browser_ext_browserAction.js]
 [browser_ext_composeAction.js]
+[browser_ext_menus.js]
 [browser_ext_mailTabs.js]
 [browser_ext_quickFilter.js]
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/test/browser/browser_ext_menus.js
@@ -0,0 +1,154 @@
+/* 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/. */
+
+let gAccount, gFolders;
+
+function treeClick(tree, row, column, event) {
+  let coords = tree.treeBoxObject.getCoordsForCellItem(row, tree.columns[column], "cell");
+  let treeChildren = tree.lastElementChild;
+  EventUtils.synthesizeMouse(
+    treeChildren,
+    coords.x + (coords.width / 2),
+    coords.y + (coords.height / 2),
+    event,
+    window
+  );
+}
+
+async function checkEvent(extension, menuIds, contexts) {
+  let [event, tab] = await extension.awaitMessage("onShown");
+  is(event.menuIds.length, menuIds.length);
+  for (let i = 0; i < menuIds.length; i++) {
+    is(event.menuIds[i], menuIds[i]);
+  }
+  is(event.contexts.length, contexts.length);
+  for (let i = 0; i < contexts.length; i++) {
+    is(event.contexts[i], contexts[i]);
+  }
+  return [event, tab];
+}
+
+function createExtension() {
+  return ExtensionTestUtils.loadExtension({
+    async background() {
+      for (let context of [
+        "audio",
+        "browser_action",
+        "editable",
+        "frame",
+        "image",
+        "link",
+        "page",
+        "password",
+        "selection",
+        "tab",
+        "video",
+        "message_list",
+        "folder_pane",
+      ]) {
+        browser.menus.create({ id: context, title: context, contexts: [context] });
+      }
+
+      browser.menus.onShown.addListener((...args) => {
+        browser.test.sendMessage("onShown", args);
+      });
+    },
+    manifest: {
+      applications: {
+        gecko: {
+          id: "test1@mochi.test",
+        },
+      },
+      permissions: ["accountsRead", "menus", "messagesRead"],
+    },
+  });
+}
+
+add_task(async function set_up() {
+  gAccount = createAccount();
+  gFolders = [...gAccount.incomingServer.rootFolder.subFolders];
+  createMessages(gFolders[0], 10);
+});
+
+add_task(async function test_folder_pane() {
+  let extension = createExtension();
+  await extension.startup();
+
+  let folderTree = document.getElementById("folderTree");
+  treeClick(folderTree, 1, 0, {});
+  treeClick(folderTree, 1, 0, {type: "contextmenu"});
+
+  let menu = document.getElementById("folderPaneContext");
+  await BrowserTestUtils.waitForEvent(menu, "popupshown");
+  ok(menu.querySelector("#test1_mochi_test-menuitem-_folder_pane"));
+  menu.hidePopup();
+
+  let [event] = await checkEvent(extension, ["folder_pane"], ["folder_pane", "all"]);
+  is(event.selectedFolder.accountId, gAccount.key);
+  is(event.selectedFolder.path, "/Trash");
+  ok(!event.displayedFolder);
+  ok(!event.selectedMessages);
+
+  await extension.unload();
+});
+
+add_task(async function test_thread_pane() {
+  let extension = createExtension();
+  await extension.startup();
+
+  let threadTree = document.getElementById("threadTree");
+  treeClick(threadTree, 0, 0, {});
+  treeClick(threadTree, 0, 0, {type: "contextmenu"});
+
+  let menu = document.getElementById("mailContext");
+  await BrowserTestUtils.waitForEvent(menu, "popupshown");
+  ok(menu.querySelector("#test1_mochi_test-menuitem-_message_list"));
+  menu.hidePopup();
+
+  let [event] = await checkEvent(extension, ["message_list"], ["message_list", "all"]);
+  is(event.displayedFolder.accountId, gAccount.key);
+  is(event.displayedFolder.path, "/Trash");
+  is(event.selectedMessages.cursor, null);
+  ok(!event.selectedFolder);
+
+  await extension.unload();
+});
+
+add_task(async function test_tab() {
+  async function checkTabEvent(index, active, isMail3Pane) {
+    EventUtils.synthesizeMouseAtCenter(tabs[index], {type: "contextmenu"}, window);
+
+    await BrowserTestUtils.waitForEvent(menu, "popupshown");
+    ok(menu.querySelector("#test1_mochi_test-menuitem-_tab"));
+    menu.hidePopup();
+
+    let [event, tab] = await checkEvent(extension, ["tab"], ["tab"]);
+    ok(!event.selectedFolder);
+    ok(!event.displayedFolder);
+    ok(!event.selectedMessages);
+    is(tab.active, active);
+    is(tab.index, index);
+    is(tab.isMail3Pane, isMail3Pane);
+  }
+
+  let extension = createExtension();
+  await extension.startup();
+
+  let tabmail = document.getElementById("tabmail");
+  window.openContentTab("about:config");
+  window.openContentTab("about:mozilla");
+  tabmail.openTab("folder", { folder: gFolders[0] });
+
+  let tabs = tabmail.tabbox.tabs.children;
+  let menu = document.getElementById("tabContextMenu");
+
+  await checkTabEvent(0, false, true);
+  await checkTabEvent(1, false, false);
+  await checkTabEvent(2, false, false);
+  await checkTabEvent(3, true, true);
+
+  await extension.unload();
+
+  tabmail.closeOtherTabs(tabmail.tabModes.folder.tabs[0]);
+});