Bug 1487008 - Toolbar buttons WebExtensions API. r=philipp
authorGeoff Lankow <geoff@darktrojan.net>
Thu, 27 Sep 2018 12:03:28 +1200
changeset 33279 630fc01f8e33b9dc4fe8831c8038e534ade67314
parent 33278 93d3584af75b199939a7e19ef2c6d87d44d494fa
child 33280 064d247285aaf55f3dcbdd999d5c36ae7a809cbf
push id387
push userclokep@gmail.com
push dateMon, 10 Dec 2018 21:30:47 +0000
reviewersphilipp
bugs1487008
Bug 1487008 - Toolbar buttons WebExtensions API. r=philipp
common/src/extensionSupport.jsm
mail/base/content/messenger.css
mail/components/compose/content/messengercompose.xul
mail/components/extensions/ExtensionPopups.jsm
mail/components/extensions/ExtensionToolbarButtons.jsm
mail/components/extensions/ext-mail.json
mail/components/extensions/extension.svg
mail/components/extensions/jar.mn
mail/components/extensions/moz.build
mail/components/extensions/parent/ext-browserAction.js
mail/components/extensions/parent/ext-composeAction.js
mail/components/extensions/schemas/browserAction.json
mail/components/extensions/schemas/composeAction.json
mail/themes/shared/mail/messengercompose.css
--- a/common/src/extensionSupport.jsm
+++ b/common/src/extensionSupport.jsm
@@ -214,16 +214,20 @@ var ExtensionSupport = {
       Services.wm.removeListener(this._windowListener);
       openWindowList.clear();
       openWindowList = undefined;
     }
 
     return true;
   },
 
+  get openWindows() {
+    return openWindowList.values();
+  },
+
   _windowListener: {
     // nsIWindowMediatorListener functions
     onOpenWindow(xulWindow) {
       // A new window has opened.
       let domWindow = xulWindow.docShell.domWindow;
 
       // Here we pass no caller ID, so all registered callers get notified.
       ExtensionSupport._waitForLoad(domWindow);
--- a/mail/base/content/messenger.css
+++ b/mail/base/content/messenger.css
@@ -548,16 +548,83 @@ preftab:root /* override :root */ {
 :root[lwthemeicons~="--freebusy-icon"] #button-freebusy:-moz-lwtheme {
   list-style-image: var(--freebusy-icon) !important;
 }
 
 :root[lwthemeicons~="--timezones-icon"] #button-timezones:-moz-lwtheme {
   list-style-image: var(--timezones-icon) !important;
 }
 
+/* Rules to help integrate WebExtension buttons */
+
+.webextension-action > .toolbarbutton-badge-stack > .toolbarbutton-icon {
+  height: 16px;
+  width: 16px;
+}
+
+@media not all and (min-resolution: 1.1dppx) {
+  .webextension-action {
+    list-style-image: var(--webextension-toolbar-image, inherit);
+  }
+
+  toolbar[brighttext] .webextension-action {
+    list-style-image: var(--webextension-toolbar-image-light, inherit);
+  }
+
+  toolbar:not([brighttext]) .webextension-action:-moz-lwtheme {
+    list-style-image: var(--webextension-toolbar-image-dark, inherit);
+  }
+
+  .webextension-action[cui-areatype="menu-panel"] {
+    list-style-image: var(--webextension-menupanel-image, inherit);
+  }
+
+  :root[lwt-popup-brighttext] .webextension-action[cui-areatype="menu-panel"] {
+    list-style-image: var(--webextension-menupanel-image-light, inherit);
+  }
+
+  :root:not([lwt-popup-brighttext]) .webextension-action[cui-areatype="menu-panel"]:-moz-lwtheme {
+    list-style-image: var(--webextension-menupanel-image-dark, inherit);
+  }
+
+  .webextension-menuitem {
+    list-style-image: var(--webextension-menuitem-image, inherit) !important;
+  }
+}
+
+@media (min-resolution: 1.1dppx) {
+  .webextension-action {
+    list-style-image: var(--webextension-toolbar-image-2x, inherit);
+  }
+
+  toolbar[brighttext] .webextension-action {
+    list-style-image: var(--webextension-toolbar-image-2x-light, inherit);
+  }
+
+  toolbar:not([brighttext]) .webextension-action:-moz-lwtheme {
+    list-style-image: var(--webextension-toolbar-image-2x-dark, inherit);
+  }
+
+  .webextension-action[cui-areatype="menu-panel"] {
+    list-style-image: var(--webextension-menupanel-image-2x, inherit);
+  }
+
+  :root[lwt-popup-brighttext] .webextension-action[cui-areatype="menu-panel"] {
+    list-style-image: var(--webextension-menupanel-image-2x-light, inherit);
+  }
+
+  :root:not([lwt-popup-brighttext]) .webextension-action[cui-areatype="menu-panel"]:-moz-lwtheme {
+    list-style-image: var(--webextension-menupanel-image-2x-dark, inherit);
+  }
+
+  .webextension-menuitem {
+    list-style-image: var(--webextension-menuitem-image-2x, inherit) !important;
+  }
+}
+
 /* Status bar */
 
 %ifdef XP_MACOSX
 statusbar {
   padding-right: 14px;
 }
 
 .statusbar-resizerpanel {
--- a/mail/components/compose/content/messengercompose.xul
+++ b/mail/components/compose/content/messengercompose.xul
@@ -565,16 +565,18 @@
 
   <key id="increasefontsizekb" key="&incrementFontSize.key;" observes="cmd_increaseFontStep" modifiers="accel"/>
   <key key="&incrementFontSize.key;" observes="cmd_increaseFontStep" modifiers="accel, shift"/>
   <key key="&incrementFontSize.key2;" observes="cmd_increaseFontStep" modifiers="accel"/>
 
   <key id="insertlinkkb" key="&insertLinkCmd2.key;" observes="cmd_link" modifiers="accel"/>
 </keyset>
 
+<popupset id="mainPopupSet"/>
+
 <!-- Reorder Attachments Panel -->
 <panel id="reorderAttachmentsPanel"
        orient="vertical"
        type="arrow"
        flip="slide"
        onpopupshowing="reorderAttachmentsPanelOnPopupShowing();"
        consumeoutsideclicks="false"
        noautohide="true">
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/ExtensionPopups.jsm
@@ -0,0 +1,334 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+/* This file is a much-modified copy of browser/components/extensions/ExtensionPopups.jsm. */
+
+var EXPORTED_SYMBOLS = ["BasePopup", "ViewPopup"];
+
+ChromeUtils.defineModuleGetter(this, "ExtensionParent",
+                               "resource://gre/modules/ExtensionParent.jsm");
+ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm");
+
+const {
+  DefaultWeakMap,
+  promiseEvent,
+} = ExtensionUtils;
+
+class BasePopup {
+  constructor(extension, viewNode, popupURL, browserStyle, fixedWidth = false, blockParser = false) {
+    this.extension = extension;
+    this.popupURL = popupURL;
+    this.viewNode = viewNode;
+    this.browserStyle = browserStyle;
+    this.window = viewNode.ownerGlobal;
+    this.destroyed = false;
+    this.fixedWidth = fixedWidth;
+    this.blockParser = blockParser;
+
+    extension.callOnClose(this);
+
+    this.contentReady = new Promise(resolve => {
+      this._resolveContentReady = resolve;
+    });
+
+    this.window.addEventListener("unload", this);
+    this.viewNode.addEventListener("popuphiding", this);
+    this.panel.addEventListener("popuppositioned", this, {once: true, capture: true});
+
+    this.browser = null;
+    this.browserLoaded = new Promise((resolve, reject) => {
+      this.browserLoadedDeferred = {resolve, reject};
+    });
+    this.browserReady = this.createBrowser(viewNode, popupURL);
+
+    BasePopup.instances.get(this.window).set(extension, this);
+  }
+
+  static for(extension, window) {
+    return BasePopup.instances.get(window).get(extension);
+  }
+
+  destroy() {
+    this.extension.forgetOnClose(this);
+
+    this.window.removeEventListener("unload", this);
+
+    this.destroyed = true;
+    this.browserLoadedDeferred.reject(new Error("Popup destroyed"));
+    // Ignore unhandled rejections if the "attach" method is not called.
+    this.browserLoaded.catch(() => {});
+
+    BasePopup.instances.get(this.window).delete(this.extension);
+
+    return this.browserReady.then(() => {
+      if (this.browser) {
+        this.destroyBrowser(this.browser, true);
+        this.browser.parentNode.remove();
+      }
+      if (this.stack) {
+        this.stack.remove();
+      }
+
+      if (this.viewNode) {
+        this.viewNode.removeEventListener("popuphiding", this);
+        delete this.viewNode.customRectGetter;
+      }
+
+      let {panel} = this;
+      if (panel) {
+        panel.removeEventListener("popuppositioned", this, {capture: true});
+        panel.style.removeProperty("--arrowpanel-background");
+        panel.style.removeProperty("--arrowpanel-border-color");
+      }
+
+      this.browser = null;
+      this.stack = null;
+      this.viewNode = null;
+    });
+  }
+
+  destroyBrowser(browser, finalize = false) {
+    let mm = browser.messageManager;
+    // If the browser has already been removed from the document, because the
+    // popup was closed externally, there will be no message manager here, so
+    // just replace our receiveMessage method with a stub.
+    if (mm) {
+      mm.removeMessageListener("DOMTitleChanged", this);
+      mm.removeMessageListener("Extension:BrowserBackgroundChanged", this);
+      mm.removeMessageListener("Extension:BrowserContentLoaded", this);
+      mm.removeMessageListener("Extension:BrowserResized", this);
+      mm.removeMessageListener("Extension:DOMWindowClose", this);
+    } else if (finalize) {
+      this.receiveMessage = () => {};
+    }
+  }
+
+  get panel() {
+    return this.viewNode;
+  }
+
+  receiveMessage({name, data}) {
+    switch (name) {
+      case "DOMTitleChanged":
+        this.viewNode.setAttribute("aria-label", this.browser.contentTitle);
+        break;
+
+      case "Extension:BrowserBackgroundChanged":
+        this.setBackground(data.background);
+        break;
+
+      case "Extension:BrowserContentLoaded":
+        this.browserLoadedDeferred.resolve();
+        break;
+
+      case "Extension:BrowserResized":
+        this._resolveContentReady();
+        if (this.ignoreResizes) {
+          this.dimensions = data;
+        } else {
+          this.resizeBrowser(data);
+        }
+        break;
+
+      case "Extension:DOMWindowClose":
+        this.closePopup();
+        break;
+    }
+  }
+
+  handleEvent(event) {
+    switch (event.type) {
+      case "unload":
+      case "popuphiding":
+        if (!this.destroyed) {
+          this.destroy();
+        }
+        break;
+      case "popuppositioned":
+        if (!this.destroyed) {
+          this.browserLoaded.then(() => {
+            if (this.destroyed) {
+              return;
+            }
+            this.browser.messageManager.sendAsyncMessage("Extension:GrabFocus", {});
+          }).catch(() => {
+            // If the panel closes too fast an exception is raised here and tests will fail.
+          });
+        }
+        break;
+    }
+  }
+
+  createBrowser(viewNode, popupURL = null) {
+    let document = viewNode.ownerDocument;
+
+    let stack = document.createXULElement("stack");
+    stack.setAttribute("class", "webextension-popup-stack");
+
+    let browser = document.createXULElement("browser");
+    browser.setAttribute("type", "content");
+    browser.setAttribute("disableglobalhistory", "true");
+    browser.setAttribute("transparent", "true");
+    browser.setAttribute("class", "webextension-popup-browser");
+    browser.setAttribute("webextension-view-type", "popup");
+    browser.setAttribute("tooltip", "aHTMLTooltip");
+    browser.setAttribute("contextmenu", "contentAreaContextMenu");
+    browser.setAttribute("autocompletepopup", "PopupAutoComplete");
+    browser.setAttribute("selectmenulist", "ContentSelectDropdown");
+    browser.setAttribute("selectmenuconstrained", "false");
+    browser.sameProcessAsFrameLoader = this.extension.groupFrameLoader;
+
+    // We only need flex sizing for the sake of the slide-in sub-views of the
+    // main menu panel, so that the browser occupies the full width of the view,
+    // and also takes up any extra height that's available to it.
+    browser.setAttribute("flex", "1");
+    stack.setAttribute("flex", "1");
+
+    // Note: When using noautohide panels, the popup manager will add width and
+    // height attributes to the panel, breaking our resize code, if the browser
+    // starts out smaller than 30px by 10px. This isn't an issue now, but it
+    // will be if and when we popup debugging.
+
+    this.browser = browser;
+    this.stack = stack;
+
+    let readyPromise = promiseEvent(browser, "load");
+
+    stack.appendChild(browser);
+    viewNode.appendChild(stack);
+
+    ExtensionParent.apiManager.emit("extension-browser-inserted", browser);
+
+    let setupBrowser = browser => {
+      let mm = browser.messageManager;
+      mm.addMessageListener("DOMTitleChanged", this);
+      mm.addMessageListener("Extension:BrowserBackgroundChanged", this);
+      mm.addMessageListener("Extension:BrowserContentLoaded", this);
+      mm.addMessageListener("Extension:BrowserResized", this);
+      mm.addMessageListener("Extension:DOMWindowClose", this, true);
+      return browser;
+    };
+
+    if (!popupURL) {
+      return setupBrowser(browser);
+    }
+
+    return readyPromise.then(() => {
+      setupBrowser(browser);
+      let mm = browser.messageManager;
+
+      // Sets the context information for context menus.
+      mm.loadFrameScript("chrome://browser/content/content.js", true, true);
+
+      mm.loadFrameScript(
+        "chrome://extensions/content/ext-browser-content.js", false, true);
+
+      mm.sendAsyncMessage("Extension:InitBrowser", {
+        allowScriptsToClose: true,
+        blockParser: this.blockParser,
+        fixedWidth: this.fixedWidth,
+        maxWidth: 800,
+        maxHeight: 600,
+        stylesheets: [],
+      });
+
+      browser.loadURI(popupURL, {triggeringPrincipal: this.extension.principal});
+    });
+  }
+
+  resizeBrowser({width, height, detail}) {
+    if (this.fixedWidth) {
+      // Figure out how much extra space we have on the side of the panel
+      // opposite the arrow.
+      let side = this.panel.getAttribute("side") == "top" ? "bottom" : "top";
+      let maxHeight = this.viewHeight + this.extraHeight[side];
+
+      height = Math.min(height, maxHeight);
+      this.browser.style.height = `${height}px`;
+
+      // Used by the panelmultiview code to figure out sizing without reparenting
+      // (which would destroy the browser and break us).
+      this.lastCalculatedInViewHeight = Math.max(height, this.viewHeight);
+    } else {
+      this.browser.style.width = `${width}px`;
+      this.browser.style.minWidth = `${width}px`;
+      this.browser.style.height = `${height}px`;
+      this.browser.style.minHeight = `${height}px`;
+    }
+
+    let event = new this.window.CustomEvent("WebExtPopupResized", {detail});
+    this.browser.dispatchEvent(event);
+  }
+
+  setBackground(background) {
+    // Panels inherit the applied theme (light, dark, etc) and there is a high
+    // likelihood that most extension authors will not have tested with a dark theme.
+    // If they have not set a background-color, we force it to white to ensure visibility
+    // of the extension content. Passing `null` should be treated the same as no argument,
+    // which is why we can't use default parameters here.
+    if (!background) {
+      background = "#fff";
+    }
+    if (this.panel.id != "widget-overflow") {
+      this.panel.style.setProperty("--arrowpanel-background", background);
+    }
+    if (background == "#fff") {
+      // Set a usable default color that work with the default background-color.
+      this.panel.style.setProperty("--arrowpanel-border-color", "hsla(210,4%,10%,.15)");
+    }
+    this.background = background;
+  }
+}
+
+class ViewPopup extends BasePopup {
+  constructor(extension, window, popupURL, browserStyle, fixedWidth, blockParser) {
+    let document = window.document;
+
+    let panel = document.createXULElement("panel");
+    panel.setAttribute("type", "arrow");
+    document.getElementById("mainPopupSet").appendChild(panel);
+
+    super(extension, panel, popupURL, browserStyle, fixedWidth, blockParser);
+
+    this.ignoreResizes = true;
+
+    this.shown = false;
+    this.tempPanel = panel;
+    this.tempBrowser = this.browser;
+
+    this.browser.classList.add("webextension-preload-browser");
+  }
+
+  removeTempPanel() {
+    if (this.tempPanel) {
+      this.tempPanel.remove();
+      this.tempPanel = null;
+    }
+    if (this.tempBrowser) {
+      this.tempBrowser.parentNode.remove();
+      this.tempBrowser = null;
+    }
+  }
+
+  destroy() {
+    return super.destroy().then(() => {
+      this.removeTempPanel();
+    });
+  }
+
+  closePopup() {
+    if (this.shown) {
+      this.viewNode.hidePopup();
+    } else {
+      this.destroy();
+    }
+  }
+}
+
+/**
+ * A map of active popups for a given browser window.
+ *
+ * WeakMap[window -> WeakMap[Extension -> BasePopup]]
+ */
+BasePopup.instances = new DefaultWeakMap(() => new WeakMap());
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/ExtensionToolbarButtons.jsm
@@ -0,0 +1,559 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["ToolbarButtonAPI"];
+
+ChromeUtils.defineModuleGetter(this, "ViewPopup", "resource:///modules/ExtensionPopups.jsm");
+ChromeUtils.defineModuleGetter(this, "ExtensionSupport", "resource:///modules/extensionSupport.jsm");
+ChromeUtils.import("resource://gre/modules/ExtensionCommon.jsm");
+ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm");
+ChromeUtils.import("resource://gre/modules/ExtensionParent.jsm");
+
+const {
+  EventManager,
+  ExtensionAPI,
+  makeWidgetId,
+} = ExtensionCommon;
+
+const {
+  IconDetails,
+  StartupCache,
+} = ExtensionParent;
+
+const {
+  DefaultWeakMap,
+  ExtensionError,
+} = ExtensionUtils;
+
+ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+XPCOMUtils.defineLazyGlobalGetters(this, ["InspectorUtils"]);
+
+var XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+var DEFAULT_ICON = "chrome://messenger/content/extension.svg";
+
+this.ToolbarButtonAPI = class extends ExtensionAPI {
+  /**
+   * Called when the extension is enabled.
+   *
+   * @param {String} entryName
+   *        The name of the property in the extension manifest
+   */
+  async onManifestEntry(entryName) {
+    let {extension} = this;
+    this.paint = this.paint.bind(this);
+    this.unpaint = this.unpaint.bind(this);
+
+    this.widgetId = makeWidgetId(extension.id);
+    this.id = `${this.widgetId}-${this.manifestName}-toolbarbutton`;
+
+    this.eventQueue = [];
+
+    let options = extension.manifest[entryName];
+    this.defaults = {
+      enabled: true,
+      title: options.default_title || extension.name,
+      badgeText: "",
+      badgeBackgroundColor: null,
+      popup: options.default_popup || "",
+    };
+    this.globals = Object.create(this.defaults);
+
+    this.browserStyle = options.browser_style;
+
+    this.defaults.icon = await StartupCache.get(
+      extension, [this.manifestName, "default_icon"],
+      () => IconDetails.normalize({
+        path: options.default_icon,
+        iconType: this.manifestName,
+        themeIcons: options.theme_icons,
+      }, extension));
+
+    this.iconData = new DefaultWeakMap(icons => this.getIconData(icons));
+    this.iconData.set(
+      this.defaults.icon,
+      await StartupCache.get(
+        extension, [this.manifestName, "default_icon_data"],
+        () => this.getIconData(this.defaults.icon)));
+
+    ExtensionSupport.registerWindowListener(this.id, {
+      chromeURLs: this.windowURLs,
+      onLoadWindow: window => {
+        this.paint(window);
+      },
+    });
+  }
+
+  /**
+   * Called when the extension is disabled or removed.
+   *
+   * @param reason
+   */
+  onShutdown(reason) {
+    if (reason === "APP_SHUTDOWN") {
+      return;
+    }
+
+    ExtensionSupport.unregisterWindowListener(this.id);
+    for (let window of ExtensionSupport.openWindows) {
+      if (this.windowURLs.includes(window.location.href)) {
+        this.unpaint(window);
+      }
+    }
+  }
+
+  /**
+   * Creates a toolbar button.
+   *
+   * @param {Window} window
+   */
+  makeButton(window) {
+    let {document} = window;
+    let button = document.createElementNS(XUL_NS, "toolbarbutton");
+    button.id = this.id;
+    button.classList.add("toolbarbutton-1");
+    button.classList.add("webextension-action");
+    button.classList.add("badged-button");
+    button.setAttribute("data-extensionid", this.extension.id);
+    button.addEventListener("mousedown", this);
+    this.updateButton(button, this.globals);
+    return button;
+  }
+
+  /**
+   * Adds a toolbar button to this window.
+   *
+   * @param {Window} window
+   */
+  paint(window) {
+    let {document} = window;
+    if (document.getElementById(this.id)) {
+      return;
+    }
+
+    let toolbox = document.getElementById(this.toolboxId);
+    let toolbar = document.getElementById(this.toolbarId);
+    if (!toolbox) {
+      return;
+    }
+    let button = this.makeButton(window);
+    if (toolbox.palette) {
+      toolbox.palette.appendChild(button);
+    } else {
+      toolbar.appendChild(button);
+    }
+    let currentSet = toolbar.getAttribute("currentset").split(",");
+    if (currentSet.includes(this.id)) {
+      toolbar.currentSet = currentSet.join(",");
+    } else {
+      currentSet.push(this.id);
+      toolbar.currentSet = currentSet.join(",");
+
+      let persistAttribute = toolbar.getAttribute("persist");
+      if (persistAttribute && persistAttribute.split(/\s+/).includes("currentset")) {
+        Services.xulStore.persist(toolbar, "currentset");
+      }
+    }
+  }
+
+  /**
+   * Removes the toolbar button from this window.
+   *
+   * @param {Window} window
+   */
+  unpaint(window) {
+    let {document} = window;
+    let button = document.getElementById(this.id);
+    if (button) {
+      button.remove();
+    }
+  }
+
+  /**
+   * Triggers this browser action for the given window, with the same effects as
+   * if it were clicked by a user.
+   *
+   * This has no effect if the browser action is disabled for, or not
+   * present in, the given window.
+   *
+   * @param {Window} window
+   */
+  async triggerAction(window) {
+    let {document} = window;
+    let button = document.getElementById(this.id);
+    let popupURL = this.getProperty(this.globals, "popup");
+    let enabled = this.getProperty(this.globals, "enabled");
+
+    if (button && popupURL && enabled) {
+      let popup = ViewPopup.for(this.extension, window) || this.getPopup(window, popupURL);
+      popup.viewNode.openPopup(button);
+    } else {
+      this.emit("click");
+    }
+  }
+
+  /**
+   * Event listener.
+   *
+   * @param {Event} event
+   */
+  handleEvent(event) {
+    let window = event.target.ownerGlobal;
+
+    switch (event.type) {
+      case "mousedown":
+        if (event.button == 0) {
+          this.triggerAction(window);
+        }
+        break;
+    }
+  }
+
+  /**
+   * Returns a potentially pre-loaded popup for the given URL in the given
+   * window. If a matching pre-load popup already exists, returns that.
+   * Otherwise, initializes a new one.
+   *
+   * If a pre-load popup exists which does not match, it is destroyed before a
+   * new one is created.
+   *
+   * @param {Window} window
+   *        The browser window in which to create the popup.
+   * @param {string} popupURL
+   *        The URL to load into the popup.
+   * @param {boolean} [blockParser = false]
+   *        True if the HTML parser should initially be blocked.
+   * @returns {ViewPopup}
+   */
+  getPopup(window, popupURL, blockParser = false) {
+    let popup = new ViewPopup(this.extension, window, popupURL, this.browserStyle, false, blockParser);
+    popup.ignoreResizes = false;
+    return popup;
+  }
+
+  /**
+   * Update the toolbar button |node| with the tab context data
+   * in |tabData|.
+   *
+   * @param {XULElement} node
+   *        XUL toolbarbutton to update
+   * @param {Object} tabData
+   *        Properties to set
+   * @param {boolean} sync
+   *        Whether to perform the update immediately
+   */
+  updateButton(node, tabData, sync = false) {
+    let title = tabData.title || this.extension.name;
+    let callback = () => {
+      node.setAttribute("tooltiptext", title);
+      node.setAttribute("label", title);
+
+      if (tabData.badgeText) {
+        node.setAttribute("badge", tabData.badgeText);
+      } else {
+        node.removeAttribute("badge");
+      }
+
+      if (tabData.enabled) {
+        node.removeAttribute("disabled");
+      } else {
+        node.setAttribute("disabled", "true");
+      }
+
+      let color = tabData.badgeBackgroundColor;
+      if (color) {
+        color = `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${color[3] / 255})`;
+        node.setAttribute("badgeStyle", `background-color: ${color};`);
+      } else {
+        node.removeAttribute("badgeStyle");
+      }
+
+      let {style, legacy} = this.iconData.get(tabData.icon);
+      const LEGACY_CLASS = "toolbarbutton-legacy-addon";
+      if (legacy) {
+        node.classList.add(LEGACY_CLASS);
+      } else {
+        node.classList.remove(LEGACY_CLASS);
+      }
+
+      node.setAttribute("style", style);
+    };
+    if (sync) {
+      callback();
+    } else {
+      node.ownerGlobal.requestAnimationFrame(callback);
+    }
+  }
+
+  /**
+   * Get icon properties for updating the UI.
+   *
+   * @param {Object} icons
+   *        Contains the icon information, typically the extension manifest
+   */
+  getIconData(icons) {
+    let baseSize = 16;
+    let {icon, size} = IconDetails.getPreferredIcon(icons, this.extension, baseSize);
+
+    let legacy = false;
+
+    // If the best available icon size is not divisible by 16, check if we have
+    // an 18px icon to fall back to, and trim off the padding instead.
+    if (size % 16 && typeof icon === "string" && !icon.endsWith(".svg")) {
+      let result = IconDetails.getPreferredIcon(icons, this.extension, 18);
+
+      if (result.size % 18 == 0) {
+        baseSize = 18;
+        icon = result.icon;
+        legacy = true;
+      }
+    }
+
+    let getIcon = (size, theme) => {
+      let {icon} = IconDetails.getPreferredIcon(icons, this.extension, size);
+      if (typeof icon === "object") {
+        if (icon[theme] == IconDetails.DEFAULT_ICON) {
+          icon[theme] = DEFAULT_ICON;
+        }
+        return IconDetails.escapeUrl(icon[theme]);
+      }
+      if (icon == IconDetails.DEFAULT_ICON) {
+        return DEFAULT_ICON;
+      }
+      return IconDetails.escapeUrl(icon);
+    };
+
+    let getStyle = (name, size) => {
+      return `
+        --webextension-${name}: url("${getIcon(size, "default")}");
+        --webextension-${name}-light: url("${getIcon(size, "light")}");
+        --webextension-${name}-dark: url("${getIcon(size, "dark")}");
+      `;
+    };
+
+    let style = `
+      ${getStyle("menupanel-image", 32)}
+      ${getStyle("menupanel-image-2x", 64)}
+      ${getStyle("toolbar-image", baseSize)}
+      ${getStyle("toolbar-image-2x", baseSize * 2)}
+    `;
+
+    let realIcon = getIcon(size, "default");
+
+    return {style, legacy, realIcon};
+  }
+
+  /**
+   * Update the toolbar button for a given window.
+   *
+   * @param {ChromeWindow} window
+   *        Browser chrome window.
+   */
+  updateWindow(window) {
+    let button = window.document.getElementById(this.id);
+    if (button) {
+      this.updateButton(button, this.globals);
+    }
+  }
+
+  /**
+   * Update the toolbar button when the extension changes the icon, title, url, etc.
+   * If it only changes a parameter for a single tab, `target` will be that tab.
+   * If it only changes a parameter for a single window, `target` will be that window.
+   * Otherwise `target` will be null.
+   *
+   * @param {XULElement|ChromeWindow|null} target
+   *        Browser tab or browser chrome window, may be null.
+   */
+  updateOnChange(target) {
+    if (target) {
+      let window = target.ownerGlobal;
+      if (target === window || target.selected) {
+        this.updateWindow(window);
+      }
+    } else {
+      for (let window of ExtensionSupport.openWindows) {
+        if (this.windowURLs.includes(window.location.href)) {
+          this.updateWindow(window);
+        }
+      }
+    }
+  }
+
+  /**
+   * Gets the target object and its associated values corresponding to
+   * the `details` parameter of the various get* and set* API methods.
+   *
+   * @param {Object} details
+   *        An object with optional `tabId` or `windowId` properties.
+   * @throws if both `tabId` and `windowId` are specified, or if they are invalid.
+   * @returns {Object}
+   *        An object with two properties: `target` and `values`.
+   *        - If a `tabId` was specified, `target` will be the corresponding
+   *          XULElement tab. If a `windowId` was specified, `target` will be
+   *          the corresponding ChromeWindow. Otherwise it will be `null`.
+   *        - `values` will contain the icon, title, badge, etc. associated with
+   *          the target.
+   */
+  getContextData({tabId, windowId}) {
+    if (tabId != null && windowId != null) {
+      throw new ExtensionError("Only one of tabId and windowId can be specified.");
+    }
+    let target, values;
+    // if (tabId != null) {
+    //   target = tabTracker.getTab(tabId);
+    //   values = this.tabContext.get(target);
+    // } else if (windowId != null) {
+    //   target = windowTracker.getWindow(windowId);
+    //   values = this.tabContext.get(target);
+    // } else {
+      target = null;
+      values = this.globals;
+    // }
+    return {target, values};
+  }
+
+  /**
+   * Set a global, window specific or tab specific property.
+   *
+   * @param {Object} details
+   *        An object with optional `tabId` or `windowId` properties.
+   * @param {string} prop
+   *        String property to set. Should should be one of "icon", "title",
+   *        "badgeText", "popup", "badgeBackgroundColor" or "enabled".
+   * @param {string} value
+   *        Value for prop.
+   */
+  setProperty(details, prop, value) {
+    let {target, values} = this.getContextData(details);
+    if (value === null) {
+      delete values[prop];
+    } else {
+      values[prop] = value;
+    }
+
+    this.updateOnChange(target);
+  }
+
+  /**
+   * Retrieve the value of a global, window specific or tab specific property.
+   *
+   * @param {Object} details
+   *        An object with optional `tabId` or `windowId` properties.
+   * @param {string} prop
+   *        String property to retrieve. Should should be one of "icon", "title",
+   *        "badgeText", "popup", "badgeBackgroundColor" or "enabled".
+   * @returns {string} value
+   *          Value of prop.
+   */
+  getProperty(details, prop) {
+    return this.getContextData(details).values[prop];
+  }
+
+  /**
+   * WebExtension API.
+   *
+   * @param {Object} context
+   */
+  getAPI(context) {
+    let {extension} = context;
+
+    let action = this;
+
+    return {
+      [this.manifestName]: {
+        onClicked: new EventManager({
+          context,
+          name: `${this.manifestName}.onClicked`,
+          inputHandling: true,
+          register: fire => {
+            let listener = (event, browser) => {
+              context.withPendingBrowser(browser, () => fire.sync());
+            };
+            action.on("click", listener);
+            return () => {
+              action.off("click", listener);
+            };
+          },
+        }).api(),
+
+        enable(tabId) {
+          action.setProperty({tabId}, "enabled", true);
+        },
+
+        disable(tabId) {
+          action.setProperty({tabId}, "enabled", false);
+        },
+
+        isEnabled(details) {
+          return action.getProperty(details, "enabled");
+        },
+
+        setTitle(details) {
+          action.setProperty(details, "title", details.title);
+        },
+
+        getTitle(details) {
+          return action.getProperty(details, "title");
+        },
+
+        setIcon(details) {
+          details.iconType = this.manifestName;
+
+          let icon = IconDetails.normalize(details, extension, context);
+          if (!Object.keys(icon).length) {
+            icon = null;
+          }
+          action.setProperty(details, "icon", icon);
+        },
+
+        setBadgeText(details) {
+          action.setProperty(details, "badgeText", details.text);
+        },
+
+        getBadgeText(details) {
+          return action.getProperty(details, "badgeText");
+        },
+
+        setPopup(details) {
+          // Note: Chrome resolves arguments to setIcon relative to the calling
+          // context, but resolves arguments to setPopup relative to the extension
+          // root.
+          // For internal consistency, we currently resolve both relative to the
+          // calling context.
+          let url = details.popup && context.uri.resolve(details.popup);
+          if (url && !context.checkLoadURL(url)) {
+            return Promise.reject({message: `Access denied for URL ${url}`});
+          }
+          action.setProperty(details, "popup", url);
+          return Promise.resolve(null);
+        },
+
+        getPopup(details) {
+          return action.getProperty(details, "popup");
+        },
+
+        setBadgeBackgroundColor(details) {
+          let color = details.color;
+          if (typeof color == "string") {
+            let col = InspectorUtils.colorToRGBA(color);
+            if (!col) {
+              throw new ExtensionError(`Invalid badge background color: "${color}"`);
+            }
+            color = col && [col.r, col.g, col.b, Math.round(col.a * 255)];
+          }
+          action.setProperty(details, "badgeBackgroundColor", color);
+        },
+
+        getBadgeBackgroundColor(details, callback) {
+          let color = action.getProperty(details, "badgeBackgroundColor");
+          return color || [0xd9, 0, 0, 255];
+        },
+
+        openPopup() {
+          throw new Error("Not implemented");
+        },
+      },
+    };
+  }
+};
--- a/mail/components/extensions/ext-mail.json
+++ b/mail/components/extensions/ext-mail.json
@@ -1,9 +1,27 @@
 {
+  "browserAction": {
+    "url": "chrome://messenger/content/parent/ext-browserAction.js",
+    "schema": "chrome://messenger/content/schemas/browserAction.json",
+    "scopes": ["addon_parent"],
+    "manifest": ["browser_action"],
+    "paths": [
+      ["browserAction"]
+    ]
+  },
+  "composeAction": {
+    "url": "chrome://messenger/content/parent/ext-composeAction.js",
+    "schema": "chrome://messenger/content/schemas/composeAction.json",
+    "scopes": ["addon_parent"],
+    "manifest": ["compose_action"],
+    "paths": [
+      ["composeAction"]
+    ]
+  },
   "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",
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/extension.svg
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
+     width="64" height="64" viewBox="0 0 64 64">
+  <defs>
+    <style>
+      .style-puzzle-piece {
+        fill: url('#gradient-linear-puzzle-piece');
+      }
+    </style>
+    <linearGradient id="gradient-linear-puzzle-piece" x1="0%" y1="0%" x2="0%" y2="100%">
+      <stop offset="0%" stop-color="#66cc52" stop-opacity="1"/>
+      <stop offset="100%" stop-color="#60bf4c" stop-opacity="1"/>
+    </linearGradient>
+  </defs>
+  <path class="style-puzzle-piece" d="M42,62c2.2,0,4-1.8,4-4l0-14.2c0,0,0.4-3.7,2.8-3.7c2.4,0,2.2,3.9,6.7,3.9c2.3,0,6.2-1.2,6.2-8.2 c0-7-3.9-7.9-6.2-7.9c-4.5,0-4.3,3.7-6.7,3.7c-2.4,0-2.8-3.8-2.8-3.8V22c0-2.2-1.8-4-4-4H31.5c0,0-3.4-0.6-3.4-3 c0-2.4,3.8-2.6,3.8-7.1c0-2.3-1.3-5.9-8.3-5.9s-8,3.6-8,5.9c0,4.5,3.4,4.7,3.4,7.1c0,2.4-3.4,3-3.4,3H6c-2.2,0-4,1.8-4,4l0,7.8 c0,0-0.4,6,4.4,6c3.1,0,3.2-4.1,7.3-4.1c2,0,4,1.9,4,6c0,4.2-2,6.3-4,6.3c-4,0-4.2-4.1-7.3-4.1c-4.8,0-4.4,5.8-4.4,5.8L2,58 c0,2.2,1.8,4,4,4H19c0,0,6.3,0.4,6.3-4.4c0-3.1-4-3.6-4-7.7c0-2,2.2-4.5,6.4-4.5c4.2,0,6.6,2.5,6.6,4.5c0,4-3.9,4.6-3.9,7.7 c0,4.9,6.3,4.4,6.3,4.4H42z"/>
+</svg>
--- a/mail/components/extensions/jar.mn
+++ b/mail/components/extensions/jar.mn
@@ -1,18 +1,23 @@
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 messenger.jar:
     content/messenger/ext-mail.json                (ext-mail.json)
+    content/messenger/extension.svg                (extension.svg)
 
+    content/messenger/parent/ext-browserAction.js  (parent/ext-browserAction.js)
+    content/messenger/parent/ext-composeAction.js  (parent/ext-composeAction.js)
     content/messenger/parent/ext-legacy.js         (parent/ext-legacy.js)
     content/messenger/parent/ext-mail.js           (parent/ext-mail.js)
     content/messenger/parent/ext-tabs.js           (parent/ext-tabs.js)
     content/messenger/parent/ext-windows.js        (parent/ext-windows.js)
 
     content/messenger/child/ext-tabs.js            (child/ext-tabs.js)
     content/messenger/child/ext-mail.js            (child/ext-mail.js)
 
+    content/messenger/schemas/browserAction.json   (schemas/browserAction.json)
+    content/messenger/schemas/composeAction.json   (schemas/composeAction.json)
     content/messenger/schemas/legacy.json   (schemas/legacy.json)
     content/messenger/schemas/tabs.json     (schemas/tabs.json)
     content/messenger/schemas/windows.json  (schemas/windows.json)
--- a/mail/components/extensions/moz.build
+++ b/mail/components/extensions/moz.build
@@ -1,9 +1,14 @@
 # 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/.
 
 EXTRA_COMPONENTS += [
     'extensions-mail.manifest',
 ]
 
+EXTRA_JS_MODULES += [
+    'ExtensionPopups.jsm',
+    'ExtensionToolbarButtons.jsm',
+]
+
 JAR_MANIFESTS += ['jar.mn']
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/parent/ext-browserAction.js
@@ -0,0 +1,16 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+ChromeUtils.defineModuleGetter(this, "ToolbarButtonAPI", "resource:///modules/ExtensionToolbarButtons.jsm");
+
+this.browserAction = class extends ToolbarButtonAPI {
+  constructor(extension) {
+    super(extension);
+    this.manifest_name = "browser_action";
+    this.manifestName = "browserAction";
+    this.windowURLs = ["chrome://messenger/content/messenger.xul"];
+    this.toolboxId = "mail-toolbox";
+    this.toolbarId = "mail-bar3";
+  }
+};
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/parent/ext-composeAction.js
@@ -0,0 +1,37 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+ChromeUtils.defineModuleGetter(this, "ToolbarButtonAPI", "resource:///modules/ExtensionToolbarButtons.jsm");
+
+this.composeAction = class extends ToolbarButtonAPI {
+  constructor(extension) {
+    super(extension);
+    this.manifest_name = "compose_action";
+    this.manifestName = "composeAction";
+    this.windowURLs = ["chrome://messenger/content/messengercompose/messengercompose.xul"];
+
+    let format = extension.manifest.compose_action.default_area == "formattoolbar";
+    this.toolboxId = format ? "FormatToolbox" : "compose-toolbox";
+    this.toolbarId = format ? "FormatToolbar" : "composeToolbar2";
+
+    if (format) {
+      this.paint = this.paintFormatToolbar;
+    }
+  }
+
+  paintFormatToolbar(window) {
+    let {document} = window;
+    if (document.getElementById(this.id)) {
+        return;
+    }
+
+    let toolbar = document.getElementById(this.toolbarId);
+    let button = this.makeButton(window);
+    let before = toolbar.lastElementChild;
+    while (before.localName == "spacer") {
+      before = before.previousElementSibling;
+    }
+    toolbar.insertBefore(button, before.nextElementSibling);
+  }
+};
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/schemas/browserAction.json
@@ -0,0 +1,417 @@
+[
+  {
+    "namespace": "manifest",
+    "types": [
+      {
+        "$extend": "WebExtensionManifest",
+        "properties": {
+          "browser_action": {
+            "type": "object",
+            "additionalProperties": { "$ref": "UnrecognizedProperty" },
+            "properties": {
+              "default_title": {
+                "type": "string",
+                "optional": true,
+                "preprocess": "localize"
+              },
+              "default_icon": {
+                "$ref": "IconPath",
+                "optional": true
+              },
+              "theme_icons": {
+                "type": "array",
+                "optional": true,
+                "minItems": 1,
+                "items": { "$ref": "ThemeIcons" },
+                "description": "Specifies icons to use for dark and light themes"
+              },
+              "default_popup": {
+                "type": "string",
+                "format": "relativeUrl",
+                "optional": true,
+                "preprocess": "localize"
+              },
+              "browser_style": {
+                "type": "boolean",
+                "optional": true,
+                "default": false
+              },
+              "default_area": {
+                "description": "Defines the location the browserAction will appear by default.  The default location is navbar.",
+                "type": "string",
+                "enum": [],
+                "optional": true
+              }
+            },
+            "optional": true
+          }
+        }
+      }
+    ]
+  },
+  {
+    "namespace": "browserAction",
+    "description": "Use toolbar actions to put icons in the mail window toolbar. In addition to its icon, a toolbar action can also have a tooltip, a badge, and a popup. This namespace is called browserAction for compatibility with browser WebExtensions.",
+    "types": [
+      {
+        "id": "Details",
+        "type": "object",
+        "description": "Specifies to which tab or window the value should be set, or from which one it should be retrieved. If no tab nor window is specified, the global value is set or retrieved.",
+        "properties": {
+          "tabId": {
+            "type": "integer",
+            "optional": true,
+            "minimum": 0,
+            "description": "When setting a value, it will be specific to the specified tab, and will automatically reset when the tab navigates. When getting, specifies the tab to get the value from; if there is no tab-specific value, the window one will be inherited."
+          },
+          "windowId": {
+            "type": "integer",
+            "optional": true,
+            "minimum": -2,
+            "description": "When setting a value, it will be specific to the specified window. When getting, specifies the window to get the value from; if there is no window-specific value, the global one will be inherited."
+          }
+        }
+      },
+      {
+        "id": "ColorArray",
+        "type": "array",
+        "items": {
+          "type": "integer",
+          "minimum": 0,
+          "maximum": 255
+        },
+        "minItems": 4,
+        "maxItems": 4
+      },
+      {
+        "id": "ImageDataType",
+        "type": "object",
+        "isInstanceOf": "ImageData",
+        "additionalProperties": { "type": "any" },
+        "postprocess": "convertImageDataToURL",
+        "description": "Pixel data for an image. Must be an ImageData object (for example, from a <code>canvas</code> element)."
+      }
+    ],
+    "functions": [
+      {
+        "name": "setTitle",
+        "type": "function",
+        "description": "Sets the title of the toolbar action. This shows up in the tooltip.",
+        "async": "callback",
+        "parameters": [
+          {
+            "name": "details",
+            "type": "object",
+            "$import": "Details",
+            "properties": {
+              "title": {
+                "choices": [
+                  {"type": "string"},
+                  {"type": "null"}
+                ],
+                "description": "The string the toolbar action should display when moused over."
+              }
+            }
+          },
+          {
+            "type": "function",
+            "name": "callback",
+            "optional": true,
+            "parameters": []
+          }
+        ]
+      },
+      {
+        "name": "getTitle",
+        "type": "function",
+        "description": "Gets the title of the toolbar action.",
+        "async": "callback",
+        "parameters": [
+          {
+            "name": "details",
+            "$ref": "Details"
+          },
+          {
+            "type": "function",
+            "name": "callback",
+            "parameters": [
+              {
+                "name": "result",
+                "type": "string"
+              }
+            ]
+          }
+        ]
+      },
+      {
+        "name": "setIcon",
+        "type": "function",
+        "description": "Sets the icon for the toolbar action. The icon can be specified either as the path to an image file or as the pixel data from a canvas element, or as dictionary of either one of those. Either the <b>path</b> or the <b>imageData</b> property must be specified.",
+        "async": "callback",
+        "parameters": [
+          {
+            "name": "details",
+            "type": "object",
+            "$import": "Details",
+            "properties": {
+              "imageData": {
+                "choices": [
+                  { "$ref": "ImageDataType" },
+                  {
+                    "type": "object",
+                    "patternProperties": {
+                      "^[1-9]\\d*$": {"$ref": "ImageDataType"}
+                    }
+                  }
+                ],
+                "optional": true,
+                "description": "Either an ImageData object or a dictionary {size -> ImageData} representing icon to be set. If the icon is specified as a dictionary, the actual image to be used is chosen depending on screen's pixel density. If the number of image pixels that fit into one screen space unit equals <code>scale</code>, then image with size <code>scale</code> * 19 will be selected. Initially only scales 1 and 2 will be supported. At least one image must be specified. Note that 'details.imageData = foo' is equivalent to 'details.imageData = {'19': foo}'"
+              },
+              "path": {
+                "choices": [
+                  { "type": "string" },
+                  {
+                    "type": "object",
+                    "patternProperties": {
+                      "^[1-9]\\d*$": { "type": "string" }
+                    }
+                  }
+                ],
+                "optional": true,
+                "description": "Either a relative image path or a dictionary {size -> relative image path} pointing to icon to be set. If the icon is specified as a dictionary, the actual image to be used is chosen depending on screen's pixel density. If the number of image pixels that fit into one screen space unit equals <code>scale</code>, then image with size <code>scale</code> * 19 will be selected. Initially only scales 1 and 2 will be supported. At least one image must be specified. Note that 'details.path = foo' is equivalent to 'details.imageData = {'19': foo}'"
+              }
+            }
+          },
+          {
+            "type": "function",
+            "name": "callback",
+            "optional": true,
+            "parameters": []
+          }
+        ]
+      },
+      {
+        "name": "setPopup",
+        "type": "function",
+        "description": "Sets the html document to be opened as a popup when the user clicks on the toolbar action's icon.",
+        "async": "callback",
+        "parameters": [
+          {
+            "name": "details",
+            "type": "object",
+            "$import": "Details",
+            "properties": {
+              "popup": {
+                "choices": [
+                  {"type": "string"},
+                  {"type": "null"}
+                ],
+                "description": "The html file to show in a popup.  If set to the empty string (''), no popup is shown."
+              }
+            }
+          },
+          {
+            "type": "function",
+            "name": "callback",
+            "optional": true,
+            "parameters": []
+          }
+        ]
+      },
+      {
+        "name": "getPopup",
+        "type": "function",
+        "description": "Gets the html document set as the popup for this toolbar action.",
+        "async": "callback",
+        "parameters": [
+          {
+            "name": "details",
+            "$ref": "Details"
+          },
+          {
+            "type": "function",
+            "name": "callback",
+            "parameters": [
+              {
+                "name": "result",
+                "type": "string"
+              }
+            ]
+          }
+        ]
+      },
+      {
+        "name": "setBadgeText",
+        "type": "function",
+        "description": "Sets the badge text for the toolbar action. The badge is displayed on top of the icon.",
+        "async": "callback",
+        "parameters": [
+          {
+            "name": "details",
+            "type": "object",
+            "$import": "Details",
+            "properties": {
+              "text": {
+                "choices": [
+                  {"type": "string"},
+                  {"type": "null"}
+                ],
+                "description": "Any number of characters can be passed, but only about four can fit in the space."
+              }
+            }
+          },
+          {
+            "type": "function",
+            "name": "callback",
+            "optional": true,
+            "parameters": []
+          }
+        ]
+      },
+      {
+        "name": "getBadgeText",
+        "type": "function",
+        "description": "Gets the badge text of the toolbar action. If no tab nor window is specified is specified, the global badge text is returned.",
+        "async": "callback",
+        "parameters": [
+          {
+            "name": "details",
+            "$ref": "Details"
+          },
+          {
+            "type": "function",
+            "name": "callback",
+            "parameters": [
+              {
+                "name": "result",
+                "type": "string"
+              }
+            ]
+          }
+        ]
+      },
+      {
+        "name": "setBadgeBackgroundColor",
+        "type": "function",
+        "description": "Sets the background color for the badge.",
+        "async": "callback",
+        "parameters": [
+          {
+            "name": "details",
+            "type": "object",
+            "$import": "Details",
+            "properties": {
+              "color": {
+                "description": "An array of four integers in the range [0,255] that make up the RGBA color of the badge. For example, opaque red is <code>[255, 0, 0, 255]</code>. Can also be a string with a CSS value, with opaque red being <code>#FF0000</code> or <code>#F00</code>.",
+                "choices": [
+                  {"type": "string"},
+                  {"$ref": "ColorArray"},
+                  {"type": "null"}
+                ]
+              }
+            }
+          },
+          {
+            "type": "function",
+            "name": "callback",
+            "optional": true,
+            "parameters": []
+          }
+        ]
+      },
+      {
+        "name": "getBadgeBackgroundColor",
+        "type": "function",
+        "description": "Gets the background color of the toolbar action.",
+        "async": "callback",
+        "parameters": [
+          {
+            "name": "details",
+            "$ref": "Details"
+          },
+          {
+            "type": "function",
+            "name": "callback",
+            "parameters": [
+              {
+                "name": "result",
+                "$ref": "ColorArray"
+              }
+            ]
+          }
+        ]
+      },
+      {
+        "name": "enable",
+        "type": "function",
+        "description": "Enables the toolbar action for a tab. By default, toolbar actions are enabled.",
+        "async": "callback",
+        "parameters": [
+          {
+            "type": "integer",
+            "optional": true,
+            "name": "tabId",
+            "minimum": 0,
+            "description": "The id of the tab for which you want to modify the toolbar action."
+          },
+          {
+            "type": "function",
+            "name": "callback",
+            "optional": true,
+            "parameters": []
+          }
+        ]
+      },
+      {
+        "name": "disable",
+        "type": "function",
+        "description": "Disables the toolbar action for a tab.",
+        "async": "callback",
+        "parameters": [
+          {
+            "type": "integer",
+            "optional": true,
+            "name": "tabId",
+            "minimum": 0,
+            "description": "The id of the tab for which you want to modify the toolbar action."
+          },
+          {
+            "type": "function",
+            "name": "callback",
+            "optional": true,
+            "parameters": []
+          }
+        ]
+      },
+      {
+        "name": "isEnabled",
+        "type": "function",
+        "description": "Checks whether the toolbar action is enabled.",
+        "async": true,
+        "parameters": [
+          {
+            "name": "details",
+            "$ref": "Details"
+          }
+        ]
+      },
+      {
+        "name": "openPopup",
+        "type": "function",
+        "requireUserInput": true,
+        "description": "Opens the extension popup window in the active window.",
+        "async": true,
+        "parameters": []
+      }
+    ],
+    "events": [
+      {
+        "name": "onClicked",
+        "type": "function",
+        "description": "Fired when a toolbar action icon is clicked.  This event will not fire if the toolbar action has a popup.",
+        "parameters": [
+        ]
+      }
+    ]
+  }
+]
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/schemas/composeAction.json
@@ -0,0 +1,417 @@
+[
+  {
+    "namespace": "manifest",
+    "types": [
+      {
+        "$extend": "WebExtensionManifest",
+        "properties": {
+          "compose_action": {
+            "type": "object",
+            "additionalProperties": { "$ref": "UnrecognizedProperty" },
+            "properties": {
+              "default_title": {
+                "type": "string",
+                "optional": true,
+                "preprocess": "localize"
+              },
+              "default_icon": {
+                "$ref": "IconPath",
+                "optional": true
+              },
+              "theme_icons": {
+                "type": "array",
+                "optional": true,
+                "minItems": 1,
+                "items": { "$ref": "ThemeIcons" },
+                "description": "Specifies icons to use for dark and light themes"
+              },
+              "default_popup": {
+                "type": "string",
+                "format": "relativeUrl",
+                "optional": true,
+                "preprocess": "localize"
+              },
+              "browser_style": {
+                "type": "boolean",
+                "optional": true,
+                "default": false
+              },
+              "default_area": {
+                "description": "Defines the location the composeAction will appear by default. The default location is maintoolbar.",
+                "type": "string",
+                "enum": ["maintoolbar", "formattoolbar"],
+                "optional": true
+              }
+            },
+            "optional": true
+          }
+        }
+      }
+    ]
+  },
+  {
+    "namespace": "composeAction",
+    "description": "Use toolbar actions to put icons in the message composition toolbars. In addition to its icon, a toolbar action can also have a tooltip, a badge, and a popup.",
+    "types": [
+      {
+        "id": "Details",
+        "type": "object",
+        "description": "Specifies to which tab or window the value should be set, or from which one it should be retrieved. If no tab nor window is specified, the global value is set or retrieved.",
+        "properties": {
+          "tabId": {
+            "type": "integer",
+            "optional": true,
+            "minimum": 0,
+            "description": "When setting a value, it will be specific to the specified tab, and will automatically reset when the tab navigates. When getting, specifies the tab to get the value from; if there is no tab-specific value, the window one will be inherited."
+          },
+          "windowId": {
+            "type": "integer",
+            "optional": true,
+            "minimum": -2,
+            "description": "When setting a value, it will be specific to the specified window. When getting, specifies the window to get the value from; if there is no window-specific value, the global one will be inherited."
+          }
+        }
+      },
+      {
+        "id": "ColorArray",
+        "type": "array",
+        "items": {
+          "type": "integer",
+          "minimum": 0,
+          "maximum": 255
+        },
+        "minItems": 4,
+        "maxItems": 4
+      },
+      {
+        "id": "ImageDataType",
+        "type": "object",
+        "isInstanceOf": "ImageData",
+        "additionalProperties": { "type": "any" },
+        "postprocess": "convertImageDataToURL",
+        "description": "Pixel data for an image. Must be an ImageData object (for example, from a <code>canvas</code> element)."
+      }
+    ],
+    "functions": [
+      {
+        "name": "setTitle",
+        "type": "function",
+        "description": "Sets the title of the toolbar action. This shows up in the tooltip.",
+        "async": "callback",
+        "parameters": [
+          {
+            "name": "details",
+            "type": "object",
+            "$import": "Details",
+            "properties": {
+              "title": {
+                "choices": [
+                  {"type": "string"},
+                  {"type": "null"}
+                ],
+                "description": "The string the toolbar action should display when moused over."
+              }
+            }
+          },
+          {
+            "type": "function",
+            "name": "callback",
+            "optional": true,
+            "parameters": []
+          }
+        ]
+      },
+      {
+        "name": "getTitle",
+        "type": "function",
+        "description": "Gets the title of the toolbar action.",
+        "async": "callback",
+        "parameters": [
+          {
+            "name": "details",
+            "$ref": "Details"
+          },
+          {
+            "type": "function",
+            "name": "callback",
+            "parameters": [
+              {
+                "name": "result",
+                "type": "string"
+              }
+            ]
+          }
+        ]
+      },
+      {
+        "name": "setIcon",
+        "type": "function",
+        "description": "Sets the icon for the toolbar action. The icon can be specified either as the path to an image file or as the pixel data from a canvas element, or as dictionary of either one of those. Either the <b>path</b> or the <b>imageData</b> property must be specified.",
+        "async": "callback",
+        "parameters": [
+          {
+            "name": "details",
+            "type": "object",
+            "$import": "Details",
+            "properties": {
+              "imageData": {
+                "choices": [
+                  { "$ref": "ImageDataType" },
+                  {
+                    "type": "object",
+                    "patternProperties": {
+                      "^[1-9]\\d*$": {"$ref": "ImageDataType"}
+                    }
+                  }
+                ],
+                "optional": true,
+                "description": "Either an ImageData object or a dictionary {size -> ImageData} representing icon to be set. If the icon is specified as a dictionary, the actual image to be used is chosen depending on screen's pixel density. If the number of image pixels that fit into one screen space unit equals <code>scale</code>, then image with size <code>scale</code> * 19 will be selected. Initially only scales 1 and 2 will be supported. At least one image must be specified. Note that 'details.imageData = foo' is equivalent to 'details.imageData = {'19': foo}'"
+              },
+              "path": {
+                "choices": [
+                  { "type": "string" },
+                  {
+                    "type": "object",
+                    "patternProperties": {
+                      "^[1-9]\\d*$": { "type": "string" }
+                    }
+                  }
+                ],
+                "optional": true,
+                "description": "Either a relative image path or a dictionary {size -> relative image path} pointing to icon to be set. If the icon is specified as a dictionary, the actual image to be used is chosen depending on screen's pixel density. If the number of image pixels that fit into one screen space unit equals <code>scale</code>, then image with size <code>scale</code> * 19 will be selected. Initially only scales 1 and 2 will be supported. At least one image must be specified. Note that 'details.path = foo' is equivalent to 'details.imageData = {'19': foo}'"
+              }
+            }
+          },
+          {
+            "type": "function",
+            "name": "callback",
+            "optional": true,
+            "parameters": []
+          }
+        ]
+      },
+      {
+        "name": "setPopup",
+        "type": "function",
+        "description": "Sets the html document to be opened as a popup when the user clicks on the toolbar action's icon.",
+        "async": "callback",
+        "parameters": [
+          {
+            "name": "details",
+            "type": "object",
+            "$import": "Details",
+            "properties": {
+              "popup": {
+                "choices": [
+                  {"type": "string"},
+                  {"type": "null"}
+                ],
+                "description": "The html file to show in a popup.  If set to the empty string (''), no popup is shown."
+              }
+            }
+          },
+          {
+            "type": "function",
+            "name": "callback",
+            "optional": true,
+            "parameters": []
+          }
+        ]
+      },
+      {
+        "name": "getPopup",
+        "type": "function",
+        "description": "Gets the html document set as the popup for this toolbar action.",
+        "async": "callback",
+        "parameters": [
+          {
+            "name": "details",
+            "$ref": "Details"
+          },
+          {
+            "type": "function",
+            "name": "callback",
+            "parameters": [
+              {
+                "name": "result",
+                "type": "string"
+              }
+            ]
+          }
+        ]
+      },
+      {
+        "name": "setBadgeText",
+        "type": "function",
+        "description": "Sets the badge text for the toolbar action. The badge is displayed on top of the icon.",
+        "async": "callback",
+        "parameters": [
+          {
+            "name": "details",
+            "type": "object",
+            "$import": "Details",
+            "properties": {
+              "text": {
+                "choices": [
+                  {"type": "string"},
+                  {"type": "null"}
+                ],
+                "description": "Any number of characters can be passed, but only about four can fit in the space."
+              }
+            }
+          },
+          {
+            "type": "function",
+            "name": "callback",
+            "optional": true,
+            "parameters": []
+          }
+        ]
+      },
+      {
+        "name": "getBadgeText",
+        "type": "function",
+        "description": "Gets the badge text of the toolbar action. If no tab nor window is specified is specified, the global badge text is returned.",
+        "async": "callback",
+        "parameters": [
+          {
+            "name": "details",
+            "$ref": "Details"
+          },
+          {
+            "type": "function",
+            "name": "callback",
+            "parameters": [
+              {
+                "name": "result",
+                "type": "string"
+              }
+            ]
+          }
+        ]
+      },
+      {
+        "name": "setBadgeBackgroundColor",
+        "type": "function",
+        "description": "Sets the background color for the badge.",
+        "async": "callback",
+        "parameters": [
+          {
+            "name": "details",
+            "type": "object",
+            "$import": "Details",
+            "properties": {
+              "color": {
+                "description": "An array of four integers in the range [0,255] that make up the RGBA color of the badge. For example, opaque red is <code>[255, 0, 0, 255]</code>. Can also be a string with a CSS value, with opaque red being <code>#FF0000</code> or <code>#F00</code>.",
+                "choices": [
+                  {"type": "string"},
+                  {"$ref": "ColorArray"},
+                  {"type": "null"}
+                ]
+              }
+            }
+          },
+          {
+            "type": "function",
+            "name": "callback",
+            "optional": true,
+            "parameters": []
+          }
+        ]
+      },
+      {
+        "name": "getBadgeBackgroundColor",
+        "type": "function",
+        "description": "Gets the background color of the toolbar action.",
+        "async": "callback",
+        "parameters": [
+          {
+            "name": "details",
+            "$ref": "Details"
+          },
+          {
+            "type": "function",
+            "name": "callback",
+            "parameters": [
+              {
+                "name": "result",
+                "$ref": "ColorArray"
+              }
+            ]
+          }
+        ]
+      },
+      {
+        "name": "enable",
+        "type": "function",
+        "description": "Enables the toolbar action for a tab. By default, toolbar actions are enabled.",
+        "async": "callback",
+        "parameters": [
+          {
+            "type": "integer",
+            "optional": true,
+            "name": "tabId",
+            "minimum": 0,
+            "description": "The id of the tab for which you want to modify the toolbar action."
+          },
+          {
+            "type": "function",
+            "name": "callback",
+            "optional": true,
+            "parameters": []
+          }
+        ]
+      },
+      {
+        "name": "disable",
+        "type": "function",
+        "description": "Disables the toolbar action for a tab.",
+        "async": "callback",
+        "parameters": [
+          {
+            "type": "integer",
+            "optional": true,
+            "name": "tabId",
+            "minimum": 0,
+            "description": "The id of the tab for which you want to modify the toolbar action."
+          },
+          {
+            "type": "function",
+            "name": "callback",
+            "optional": true,
+            "parameters": []
+          }
+        ]
+      },
+      {
+        "name": "isEnabled",
+        "type": "function",
+        "description": "Checks whether the toolbar action is enabled.",
+        "async": true,
+        "parameters": [
+          {
+            "name": "details",
+            "$ref": "Details"
+          }
+        ]
+      },
+      {
+        "name": "openPopup",
+        "type": "function",
+        "requireUserInput": true,
+        "description": "Opens the extension popup window in the active window.",
+        "async": true,
+        "parameters": []
+      }
+    ],
+    "events": [
+      {
+        "name": "onClicked",
+        "type": "function",
+        "description": "Fired when a toolbar action icon is clicked.  This event will not fire if the toolbar action has a popup.",
+        "parameters": [
+        ]
+      }
+    ]
+  }
+]
--- a/mail/themes/shared/mail/messengercompose.css
+++ b/mail/themes/shared/mail/messengercompose.css
@@ -5,16 +5,83 @@
 @namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul");
 
 :root {
   --lwt-additional-images: none;
   --lwt-background-alignment: right top;
   --lwt-background-tiling: no-repeat;
 }
 
+/* Rules to help integrate WebExtension buttons */
+
+.webextension-browser-action > .toolbarbutton-badge-stack > .toolbarbutton-icon {
+  height: 16px;
+  width: 16px;
+}
+
+@media not all and (min-resolution: 1.1dppx) {
+  .webextension-browser-action {
+    list-style-image: var(--webextension-toolbar-image, inherit);
+  }
+
+  toolbar[brighttext] .webextension-browser-action {
+    list-style-image: var(--webextension-toolbar-image-light, inherit);
+  }
+
+  toolbar:not([brighttext]) .webextension-browser-action:-moz-lwtheme {
+    list-style-image: var(--webextension-toolbar-image-dark, inherit);
+  }
+
+  .webextension-browser-action[cui-areatype="menu-panel"] {
+    list-style-image: var(--webextension-menupanel-image, inherit);
+  }
+
+  :root[lwt-popup-brighttext] .webextension-browser-action[cui-areatype="menu-panel"] {
+    list-style-image: var(--webextension-menupanel-image-light, inherit);
+  }
+
+  :root:not([lwt-popup-brighttext]) .webextension-browser-action[cui-areatype="menu-panel"]:-moz-lwtheme {
+    list-style-image: var(--webextension-menupanel-image-dark, inherit);
+  }
+
+  .webextension-menuitem {
+    list-style-image: var(--webextension-menuitem-image, inherit) !important;
+  }
+}
+
+@media (min-resolution: 1.1dppx) {
+  .webextension-browser-action {
+    list-style-image: var(--webextension-toolbar-image-2x, inherit);
+  }
+
+  toolbar[brighttext] .webextension-browser-action {
+    list-style-image: var(--webextension-toolbar-image-2x-light, inherit);
+  }
+
+  toolbar:not([brighttext]) .webextension-browser-action:-moz-lwtheme {
+    list-style-image: var(--webextension-toolbar-image-2x-dark, inherit);
+  }
+
+  .webextension-browser-action[cui-areatype="menu-panel"] {
+    list-style-image: var(--webextension-menupanel-image-2x, inherit);
+  }
+
+  :root[lwt-popup-brighttext] .webextension-browser-action[cui-areatype="menu-panel"] {
+    list-style-image: var(--webextension-menupanel-image-2x-light, inherit);
+  }
+
+  :root:not([lwt-popup-brighttext]) .webextension-browser-action[cui-areatype="menu-panel"]:-moz-lwtheme {
+    list-style-image: var(--webextension-menupanel-image-2x-dark, inherit);
+  }
+
+  .webextension-menuitem {
+    list-style-image: var(--webextension-menuitem-image-2x, inherit) !important;
+  }
+}
+
 #attachmentBucket {
   width: 15em;
   min-width: 15em;
 }
 
 #attachmentBucket > scrollbox > .scrollbox-innerbox {
   padding: 1px;
 }