Bug 1621841 - Update extensions prompts code to match Firefox. r=mkmelin
authorGeoff Lankow <geoff@darktrojan.net>
Thu, 12 Mar 2020 15:48:21 +1300
changeset 38508 808f7c2c6680106acaf1fe53132c65d3a888b467
parent 38507 2895f182a2d0a33359d755be5c5469e08e4795fb
child 38509 e3d50753c9d8a8ed8d5f65c14460267155ac4ec6
push id400
push userclokep@gmail.com
push dateMon, 04 May 2020 18:56:09 +0000
reviewersmkmelin
bugs1621841
Bug 1621841 - Update extensions prompts code to match Firefox. r=mkmelin This brings our code up-to-date with mozilla-central's ExtensionsUI.jsm and browser-addons.js. I've pulled apart the implementations of gXPInstallObserver and ExtensionsUI which were combined previously due to a lot of overlap. This turned out to be a mistake from a maintainability point of view.
mail/base/content/mainMailToolbox.inc.xhtml
mail/base/content/messenger.xhtml
mail/base/modules/ExtensionsUI.jsm
mail/components/MailGlue.jsm
mail/components/customizableui/content/panelUI.inc.xhtml
mail/components/customizableui/content/panelUI.js
mail/locales/en-US/chrome/messenger/addons.properties
mail/themes/shared/customizableui/panelUI.css
--- a/mail/base/content/mainMailToolbox.inc.xhtml
+++ b/mail/base/content/mainMailToolbox.inc.xhtml
@@ -305,16 +305,17 @@
     </toolbaritem>
     <toolbarbutton id="button-stop"
                    class="toolbarbutton-1"
                    label="&stopButton.label;"
                    tooltiptext="&stopButton.tooltip;"
                    command="cmd_stop"/>
     <toolbarbutton id="button-appmenu"
                    type="menu"
+                   badged="true"
                    class="toolbarbutton-1 button-appmenu"
                    label="&appmenuButton.label;"
                    tooltiptext="&appmenuButton1.tooltip;"/>
 #ifdef MAIN_WINDOW
     <!-- gloda search widget; provides global (message) searching.  -->
     <toolbaritem id="gloda-search" insertafter="button-stop"
                  title="&glodaSearch.title;"
                  align="center"
--- a/mail/base/content/messenger.xhtml
+++ b/mail/base/content/messenger.xhtml
@@ -475,16 +475,19 @@
     <popupnotificationcontent id="addon-install-confirmation-content" orient="vertical"/>
   </popupnotification>
 
   <popupnotification id="addon-webext-permissions-notification" hidden="true">
     <popupnotificationcontent class="addon-webext-perm-notification-content" orient="vertical">
       <description id="addon-webext-perm-text" class="addon-webext-perm-text"/>
       <label id="addon-webext-perm-intro" class="addon-webext-perm-text"/>
       <html:ul id="addon-webext-perm-list" class="addon-webext-perm-list"/>
+      <hbox>
+        <label id="addon-webext-perm-info" is="text-link" class="popup-notification-learnmore-link"/>
+      </hbox>
     </popupnotificationcontent>
   </popupnotification>
 
   <popupnotification id="addon-installed-notification" hidden="true">
     <popupnotificationcontent class="addon-installed-notification-content" orient="vertical">
       <html:ul id="addon-installed-list" class="addon-installed-list"/>
     </popupnotificationcontent>
   </popupnotification>
--- a/mail/base/modules/ExtensionsUI.jsm
+++ b/mail/base/modules/ExtensionsUI.jsm
@@ -1,49 +1,76 @@
 /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
  * This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 const EXPORTED_SYMBOLS = ["ExtensionsUI"];
 
-const ADDONS_PROPERTIES = "chrome://messenger/locale/addons.properties";
-const BRAND_PROPERTIES = "chrome://branding/locale/brand.properties";
-const DEFAULT_EXTENSION_ICON =
-  "chrome://mozapps/skin/extensions/extensionGeneric.svg";
-const HTML_NS = "http://www.w3.org/1999/xhtml";
-
 const { XPCOMUtils } = ChromeUtils.import(
   "resource://gre/modules/XPCOMUtils.jsm"
 );
+const { EventEmitter } = ChromeUtils.import(
+  "resource://gre/modules/EventEmitter.jsm"
+);
+
 XPCOMUtils.defineLazyModuleGetters(this, {
   AddonManager: "resource://gre/modules/AddonManager.jsm",
   AddonManagerPrivate: "resource://gre/modules/AddonManager.jsm",
+  AMTelemetry: "resource://gre/modules/AddonManager.jsm",
+  AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.jsm",
+  BrowserUtils: "resource://gre/modules/BrowserUtils.jsm",
   ExtensionData: "resource://gre/modules/Extension.jsm",
   PluralForm: "resource://gre/modules/PluralForm.jsm",
   Services: "resource://gre/modules/Services.jsm",
   setTimeout: "resource://gre/modules/Timer.jsm",
   StringBundle: "resource:///modules/StringBundle.jsm",
 });
 
+const ADDONS_PROPERTIES = "chrome://messenger/locale/addons.properties";
+
 XPCOMUtils.defineLazyGetter(this, "addonsBundle", function() {
   return new StringBundle(ADDONS_PROPERTIES);
 });
-XPCOMUtils.defineLazyGetter(this, "brandBundle", function() {
-  return new StringBundle(BRAND_PROPERTIES);
+XPCOMUtils.defineLazyGetter(this, "brandShortName", function() {
+  return new StringBundle(
+    "chrome://branding/locale/brand.properties"
+  ).getString("brandShortName");
 });
 
+XPCOMUtils.defineLazyPreferenceGetter(
+  this,
+  "WEBEXT_PERMISSION_PROMPTS",
+  "extensions.webextPermissionPrompts",
+  false
+);
+
+const DEFAULT_EXTENSION_ICON =
+  "chrome://mozapps/skin/extensions/extensionGeneric.svg";
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+
 function getTopWindow() {
   return Services.wm.getMostRecentWindow("mail:3pane");
 }
 
 function getNotification(id, browser) {
   return getTopWindow().PopupNotifications.getNotification(id, browser);
 }
 
+function getTabBrowser(browser) {
+  while (browser.ownerGlobal.docShell.itemType !== Ci.nsIDocShell.typeChrome) {
+    browser = browser.ownerGlobal.docShell.chromeEventHandler;
+  }
+  if (browser.getAttribute("webextension-view-type") == "popup") {
+    browser = browser.ownerGlobal.gBrowser.selectedBrowser;
+  }
+  return { browser, window: browser.ownerGlobal };
+}
+
 function showNotification(
   browser,
   id,
   message,
   anchorID,
   mainAction,
   secondaryActions,
   options
@@ -85,19 +112,25 @@ function removeNotificationOnEnd(notific
       onDownloadCancelled: maybeRemove,
       onDownloadFailed: maybeRemove,
       onInstallFailed: maybeRemove,
       onInstallEnded: maybeRemove,
     });
   }
 }
 
+/**
+ * This object is Thunderbird's version of the same object in
+ * browser/base/content/browser-addons.js. Firefox has one of these objects
+ * per window but Thunderbird has only one total, because we simply pick the
+ * most recent window for notifications, rather than the window related to a
+ * particular tab.
+ */
 var gXPInstallObserver = {
   pendingInstalls: new WeakMap(),
-  pendingNotifications: new WeakMap(),
 
   showInstallConfirmation(browser, installInfo, height = undefined) {
     let document = getTopWindow().document;
     // If the confirmation notification is already open cache the installInfo
     // and the new confirmation will be shown later
     if (getNotification("addon-install-confirmation", browser)) {
       let pending = this.pendingInstalls.get(browser);
       if (pending) {
@@ -133,16 +166,22 @@ var gXPInstallObserver = {
       hideClose: true,
     };
 
     let acceptInstallation = () => {
       for (let install of installInfo.installs) {
         install.install();
       }
       installInfo = null;
+
+      Services.telemetry
+        .getHistogramById("SECURITY_UI")
+        .add(
+          Ci.nsISecurityUITelemetry.WARNING_CONFIRM_ADDON_INSTALL_CLICK_THROUGH
+        );
     };
 
     let cancelInstallation = () => {
       if (installInfo) {
         for (let install of installInfo.installs) {
           // The notification may have been closed because the add-ons got
           // cancelled elsewhere, only try to cancel those that are still
           // pending install.
@@ -189,18 +228,16 @@ var gXPInstallObserver = {
     let messageString;
     let notification = document.getElementById(
       "addon-install-confirmation-notification"
     );
     messageString = addonsBundle.getString("addonConfirmInstall.message");
     notification.removeAttribute("warning");
     options.learnMoreURL += "find-and-install-add-ons";
 
-    let brandShortName = brandBundle.getString("brandShortName");
-
     messageString = PluralForm.get(installInfo.installs.length, messageString);
     messageString = messageString.replace("#1", brandShortName);
     messageString = messageString.replace("#2", installInfo.installs.length);
 
     let action = {
       label: addonsBundle.getString("addonInstall.acceptButton2.label"),
       accessKey: addonsBundle.getString("addonInstall.acceptButton2.accesskey"),
       callback: acceptInstallation,
@@ -221,158 +258,29 @@ var gXPInstallObserver = {
       "addon-install-confirmation",
       messageString,
       anchorID,
       action,
       [secondaryAction],
       options
     );
     removeNotificationOnEnd(popup, installInfo.installs);
+
+    Services.telemetry
+      .getHistogramById("SECURITY_UI")
+      .add(Ci.nsISecurityUITelemetry.WARNING_CONFIRM_ADDON_INSTALL);
   },
 
-  async showPermissionsPrompt(browser, strings, icon) {
-    let window = getTopWindow();
-
-    // Wait for any pending prompts in this window to complete before
-    // showing the next one.
-    let pending;
-    while ((pending = this.pendingNotifications.get(window))) {
-      await pending;
-    }
-
-    let promise = new Promise(resolve => {
-      function eventCallback(topic) {
-        let doc = window.document;
-        if (topic == "showing") {
-          let textEl = doc.getElementById("addon-webext-perm-text");
-          textEl.textContent = strings.text;
-          textEl.hidden = !strings.text;
-
-          let listIntroEl = doc.getElementById("addon-webext-perm-intro");
-          listIntroEl.textContent = strings.listIntro;
-          listIntroEl.hidden = strings.msgs.length == 0;
-
-          let list = doc.getElementById("addon-webext-perm-list");
-          while (list.lastChild) {
-            list.lastChild.remove();
-          }
-
-          for (let msg of strings.msgs) {
-            let item = doc.createElementNS(HTML_NS, "li");
-            item.textContent = msg;
-            list.appendChild(item);
-          }
-        } else if (topic == "swapping") {
-          return true;
-        }
-        if (topic == "removed") {
-          Services.tm.dispatchToMainThread(() => {
-            resolve(false);
-          });
-        }
-        return false;
-      }
-
-      let popupOptions = {
-        hideClose: true,
-        popupIconURL: icon || DEFAULT_EXTENSION_ICON,
-        persistent: true,
-        eventCallback,
-        name: strings.addonName,
-        removeOnDismissal: true,
-      };
-
-      let action = {
-        label: strings.acceptText,
-        accessKey: strings.acceptKey,
-        callback: () => {
-          resolve(true);
-        },
-      };
-      let secondaryActions = [
-        {
-          label: strings.cancelText,
-          accessKey: strings.cancelKey,
-          callback: () => {
-            resolve(false);
-          },
-        },
-      ];
-
-      showNotification(
-        browser,
-        "addon-webext-permissions",
-        strings.header,
-        "addons-notification-icon",
-        action,
-        secondaryActions,
-        popupOptions
-      );
-    });
-
-    this.pendingNotifications.set(window, promise);
-    promise.finally(() => this.pendingNotifications.delete(window));
-    return promise;
-  },
-
-  async showInstallNotification(browser, addon) {
-    let window = getTopWindow();
-    let document = window.document;
-
-    let brandBundle = document.getElementById("bundle_brand");
-    let appName = brandBundle.getString("brandShortName");
-
-    let message = addonsBundle.getFormattedString("addonPostInstall.message1", [
-      "<>",
-      appName,
-    ]);
-
-    let icon = DEFAULT_EXTENSION_ICON;
-    if (addon.isWebExtension) {
-      icon = AddonManager.getPreferredIconURL(addon, 32, window) || icon;
-    }
-
-    let options = {
-      hideClose: true,
-      timeout: Date.now() + 30000,
-      popupIconURL: icon,
-      name: addon.name,
-    };
-
-    let list = document.getElementById("addon-installed-list");
-    list.hidden = true;
-
-    this._showInstallNotification(browser, message, options);
-  },
-
-  _showInstallNotification(browser, message, options) {
-    showNotification(
-      browser,
-      "addon-installed",
-      message,
-      "addons-notification-icon",
-      {
-        label: addonsBundle.getString("addonPostInstall.okay.label"),
-        accessKey: addonsBundle.getString("addonPostInstall.okay.accesskey"),
-        callback: () => {},
-      },
-      null,
-      options
-    );
-  },
-
-  /* eslint-disable complexity */
   observe(subject, topic, data) {
     let installInfo = subject.wrappedJSObject;
     let browser = installInfo.browser || installInfo.target;
     let window = getTopWindow();
 
     const anchorID = "addons-notification-icon";
     var messageString, action;
-    var brandShortName = brandBundle.getString("brandShortName");
 
     var notificationID = topic;
     // Make notifications persistent
     var options = {
       displayURI: installInfo.originatingURI,
       persistent: true,
       hideClose: true,
       timeout: Date.now() + 30000,
@@ -423,67 +331,132 @@ var gXPInstallObserver = {
         break;
       }
       case "addon-install-origin-blocked": {
         messageString = addonsBundle.getFormattedString(
           "xpinstallPromptMessage",
           [brandShortName]
         );
 
+        if (Services.policies) {
+          let extensionSettings = Services.policies.getExtensionSettings("*");
+          if (
+            extensionSettings &&
+            "blocked_install_message" in extensionSettings
+          ) {
+            messageString += " " + extensionSettings.blocked_install_message;
+          }
+        }
+
         options.removeOnDismissal = true;
         options.persistent = false;
 
+        let secHistogram = Services.telemetry.getHistogramById("SECURITY_UI");
+        secHistogram.add(
+          Ci.nsISecurityUITelemetry.WARNING_ADDON_ASKING_PREVENTED
+        );
         let popup = showNotification(
           browser,
           notificationID,
           messageString,
           anchorID,
           null,
           null,
           options
         );
         removeNotificationOnEnd(popup, installInfo.installs);
         break;
       }
       case "addon-install-blocked": {
-        messageString = addonsBundle.getFormattedString(
-          "xpinstallPromptMessage",
-          [brandShortName]
-        );
+        let hasHost = !!options.displayURI;
+        if (hasHost) {
+          messageString = addonsBundle.getFormattedString(
+            "xpinstallPromptMessage.header",
+            ["<>"]
+          );
+          options.name = options.displayURI.displayHost;
+        } else {
+          messageString = addonsBundle.getString(
+            "xpinstallPromptMessage.header.unknown"
+          );
+        }
+        // displayURI becomes it's own label, so we unset it for this panel.
+        // It will become part of the messageString above.
+        options.displayURI = undefined;
 
+        options.eventCallback = topic => {
+          if (topic !== "showing") {
+            return;
+          }
+          let doc = browser.ownerDocument;
+          let message = doc.getElementById("addon-install-blocked-message");
+          // We must remove any prior use of this panel message in this window.
+          while (message.firstChild) {
+            message.firstChild.remove();
+          }
+          if (hasHost) {
+            let text = addonsBundle.getString("xpinstallPromptMessage.message");
+            let b = doc.createElementNS("http://www.w3.org/1999/xhtml", "b");
+            b.textContent = options.name;
+            let fragment = BrowserUtils.getLocalizedFragment(doc, text, b);
+            message.appendChild(fragment);
+          } else {
+            message.textContent = addonsBundle.getString(
+              "xpinstallPromptMessage.message.unknown"
+            );
+          }
+          let learnMore = doc.getElementById("addon-install-blocked-info");
+          learnMore.textContent = addonsBundle.getString(
+            "xpinstallPromptMessage.learnMore"
+          );
+          learnMore.setAttribute(
+            "href",
+            Services.urlFormatter.formatURLPref("app.support.baseURL") +
+              "unlisted-extensions-risks"
+          );
+        };
+
+        let secHistogram = Services.telemetry.getHistogramById("SECURITY_UI");
         action = {
-          label: addonsBundle.getString("xpinstallPromptAllowButton"),
+          label: addonsBundle.getString("xpinstallPromptMessage.install"),
           accessKey: addonsBundle.getString(
-            "xpinstallPromptAllowButton.accesskey"
+            "xpinstallPromptMessage.install.accesskey"
           ),
           callback() {
+            secHistogram.add(
+              Ci.nsISecurityUITelemetry
+                .WARNING_ADDON_ASKING_PREVENTED_CLICK_THROUGH
+            );
             installInfo.install();
           },
         };
-        let secondaryAction = {
+        let dontAllowAction = {
           label: addonsBundle.getString("xpinstallPromptMessage.dontAllow"),
           accessKey: addonsBundle.getString(
             "xpinstallPromptMessage.dontAllow.accesskey"
           ),
           callback: () => {
             for (let install of installInfo.installs) {
               if (install.state != AddonManager.STATE_CANCELLED) {
                 install.cancel();
               }
             }
           },
         };
 
+        secHistogram.add(
+          Ci.nsISecurityUITelemetry.WARNING_ADDON_ASKING_PREVENTED
+        );
         let popup = showNotification(
           browser,
           notificationID,
           messageString,
           anchorID,
           action,
-          [secondaryAction],
+          [dontAllowAction],
           options
         );
         removeNotificationOnEnd(popup, installInfo.installs);
         break;
       }
       case "addon-install-started": {
         let needsDownload = function(install) {
           return install.state != AddonManager.STATE_DOWNLOADED;
@@ -503,35 +476,28 @@ var gXPInstallObserver = {
           "#1",
           installInfo.installs.length
         );
         options.installs = installInfo.installs;
         options.contentWindow = browser.contentWindow;
         options.sourceURI = browser.currentURI;
         options.eventCallback = function(event) {
           switch (event) {
-            case "shown":
-              let notificationElement = [...this.owner.panel.children].find(
-                n => n.notification == this
-              );
-              if (notificationElement) {
-                notificationElement.setAttribute("mainactiondisabled", "true");
-              }
-              break;
             case "removed":
               options.contentWindow = null;
               options.sourceURI = null;
               break;
           }
         };
         action = {
           label: addonsBundle.getString("addonInstall.acceptButton2.label"),
           accessKey: addonsBundle.getString(
             "addonInstall.acceptButton2.accesskey"
           ),
+          disabled: true,
           callback: () => {},
         };
         let secondaryAction = {
           label: addonsBundle.getString("addonInstall.cancelButton.label"),
           accessKey: addonsBundle.getString(
             "addonInstall.cancelButton.accesskey"
           ),
           callback: () => {
@@ -573,31 +539,54 @@ var gXPInstallObserver = {
               install.sourceURI.host;
           }
 
           let error =
             host || install.error == 0
               ? "addonInstallError"
               : "addonLocalInstallError";
           let args;
-
-          // Temporarily replace the usual warning message with this more-likely one.
           if (install.error < 0) {
             error += install.error;
             args = [brandShortName, install.name];
           } else if (
             install.addon.blocklistState == Ci.nsIBlocklistService.STATE_BLOCKED
           ) {
             error += "Blocklisted";
             args = [install.name];
           } else {
             error += "Incompatible";
             args = [brandShortName, Services.appinfo.version, install.name];
           }
 
+          if (
+            install.addon &&
+            !Services.policies.mayInstallAddon(install.addon)
+          ) {
+            error = "addonInstallBlockedByPolicy";
+            let extensionSettings = Services.policies.getExtensionSettings(
+              install.addon.id
+            );
+            let message = "";
+            if (
+              extensionSettings &&
+              "blocked_install_message" in extensionSettings
+            ) {
+              message = " " + extensionSettings.blocked_install_message;
+            }
+            args = [install.name, install.addon.id, message];
+          }
+
+          // Add Learn More link when refusing to install an unsigned add-on
+          if (install.error == AddonManager.ERROR_SIGNEDSTATE_REQUIRED) {
+            options.learnMoreURL =
+              Services.urlFormatter.formatURLPref("app.support.baseURL") +
+              "unsigned-addons";
+          }
+
           messageString = addonsBundle.getFormattedString(error, args);
 
           showNotification(
             browser,
             notificationID,
             messageString,
             anchorID,
             action,
@@ -623,221 +612,554 @@ var gXPInstallObserver = {
 
           this._removeProgressNotification(browser);
           this.showInstallConfirmation(browser, installInfo, height);
         };
 
         let progressNotification = getNotification("addon-progress", browser);
         if (progressNotification) {
           let downloadDuration = Date.now() - progressNotification._startTime;
-          let securityDelay = Services.prefs.getIntPref(
-            "security.dialog_enable_delay"
-          );
-          if (securityDelay > downloadDuration) {
+          let securityDelay =
+            Services.prefs.getIntPref("security.dialog_enable_delay") -
+            downloadDuration;
+          if (securityDelay > 0) {
             setTimeout(() => {
               // The download may have been cancelled during the security delay
               if (getNotification("addon-progress", browser)) {
                 showNotification();
               }
-            }, securityDelay - downloadDuration);
+            }, securityDelay);
             break;
           }
         }
         showNotification();
         break;
       }
       case "addon-install-complete": {
-        this.showInstallNotification(browser, installInfo.installs[0].addon);
-        break;
-      }
-      case "webextension-permission-prompt": {
-        let { info } = subject.wrappedJSObject;
-
-        // Dismiss the progress notification.  Note that this is bad if
-        // there are multiple simultaneous installs happening, see
-        // bug 1329884 for a longer explanation.
-        let progressNotification = getNotification("addon-progress", browser);
-        if (progressNotification) {
-          progressNotification.remove();
-        }
-
-        // This is where we should check for unsigned extensions, but Thunderbird
-        // doesn't require signing, so we just skip checking.
-        info.unsigned = false;
-
-        let strings = this._buildStrings(info);
-
-        // If this is an update with no promptable permissions, just apply it
-        if (info.type == "update" && strings.msgs.length == 0) {
-          info.resolve();
-          return;
-        }
+        let secondaryActions = null;
+        let numAddons = installInfo.installs.length;
 
-        let icon = info.unsigned
-          ? "chrome://global/skin/icons/warning.svg"
-          : info.icon;
+        if (numAddons == 1) {
+          messageString = addonsBundle.getFormattedString("addonInstalled", [
+            installInfo.installs[0].name,
+          ]);
+        } else {
+          messageString = addonsBundle.getString("addonsGenericInstalled");
+          messageString = PluralForm.get(numAddons, messageString);
+          messageString = messageString.replace("#1", numAddons);
+        }
+        action = null;
 
-        this.showPermissionsPrompt(browser, strings, icon).then(answer => {
-          if (answer) {
-            info.resolve();
-          } else {
-            info.reject();
-          }
-        });
-        break;
-      }
-      case "webextension-update-permissions": {
-        let info = subject.wrappedJSObject;
-        info.type = "update";
-        let strings = this._buildStrings(info);
+        options.removeOnDismissal = true;
+        options.persistent = false;
 
-        // If we don't prompt for any new permissions, just apply it.
-        if (strings.msgs.length == 0) {
-          info.resolve();
-          return;
-        }
-
-        this.showPermissionsPrompt(browser, strings, info.addon.iconURL).then(
-          answer => {
-            if (answer) {
-              info.resolve();
-            } else {
-              info.reject();
-            }
-          }
+        showNotification(
+          browser,
+          notificationID,
+          messageString,
+          anchorID,
+          action,
+          secondaryActions,
+          options
         );
         break;
       }
-      case "webextension-install-notify": {
-        let { addon } = subject.wrappedJSObject;
-        this.showInstallNotification(browser, addon);
-        break;
-      }
-      case "webextension-optional-permission-prompt": {
-        let { name, icon, permissions, resolve } = subject.wrappedJSObject;
-        let strings = this._buildStrings({
-          type: "optional",
-          addon: { name },
-          permissions,
-        });
-
-        // If we don't have any promptable permissions, just proceed
-        if (strings.msgs.length == 0) {
-          resolve(true);
-          return;
-        }
-        resolve(this.showPermissionsPrompt(browser, strings, icon));
-        break;
-      }
     }
   },
-  /* eslint-enable complexity */
-
-  // Create a set of formatted strings for a permission prompt
-  _buildStrings(info) {
-    // This bundle isn't the same as addonsBundle.
-    let bundle = Services.strings.createBundle(ADDONS_PROPERTIES);
-    let appName = brandBundle.getString("brandShortName");
-    let info2 = Object.assign({ appName }, info);
-
-    let strings = ExtensionData.formatPermissionStrings(info2, bundle);
-    strings.addonName = info.addon.name;
-    return strings;
-  },
 
   _removeProgressNotification(browser) {
     let notification = getNotification("addon-progress", browser);
     if (notification) {
       notification.remove();
     }
   },
-
-  async _checkForSideloaded(browser) {
-    let sideloaded = await AddonManagerPrivate.getNewSideloads();
-    if (sideloaded.length == 0) {
-      return;
-    }
-
-    // Check if the user wants any sideloaded add-ons installed.
-
-    let enabled = [];
-    for (let addon of sideloaded) {
-      let strings = this._buildStrings({
-        addon,
-        permissions: addon.userPermissions,
-        type: "sideload",
-      });
-      let answer = await this.showPermissionsPrompt(
-        browser,
-        strings,
-        addon.iconURL
-      );
-      if (answer) {
-        await addon.enable();
-        enabled.push(addon);
-      }
-    }
-
-    if (enabled.length == 0) {
-      return;
-    }
-
-    // Confirm sideloaded add-ons were installed and ask to restart if necessary.
-
-    if (enabled.length == 1) {
-      this.showInstallNotification(browser, enabled[0]);
-      return;
-    }
-
-    let document = getTopWindow().document;
-
-    let brandBundle = document.getElementById("bundle_brand");
-    let appName = brandBundle.getString("brandShortName");
-
-    let message = addonsBundle.getFormattedString(
-      "addonPostInstall.multiple.message",
-      [appName]
-    );
-
-    let list = document.getElementById("addon-installed-list");
-    list.hidden = false;
-    while (list.lastChild) {
-      list.lastChild.remove();
-    }
-
-    for (let addon of enabled) {
-      let item = document.createElementNS(HTML_NS, "li");
-      item.textContent = addon.name;
-      list.appendChild(item);
-    }
-
-    let options = {
-      popupIconURL: DEFAULT_EXTENSION_ICON,
-      hideClose: true,
-      timeout: Date.now() + 30000,
-    };
-
-    this._showInstallNotification(browser, message, options);
-  },
 };
 
 Services.obs.addObserver(gXPInstallObserver, "addon-install-disabled");
 Services.obs.addObserver(gXPInstallObserver, "addon-install-origin-blocked");
 Services.obs.addObserver(gXPInstallObserver, "addon-install-blocked");
 Services.obs.addObserver(gXPInstallObserver, "addon-install-started");
 Services.obs.addObserver(gXPInstallObserver, "addon-install-failed");
 Services.obs.addObserver(gXPInstallObserver, "addon-install-confirmation");
 Services.obs.addObserver(gXPInstallObserver, "addon-install-complete");
-Services.obs.addObserver(gXPInstallObserver, "webextension-permission-prompt");
-Services.obs.addObserver(gXPInstallObserver, "webextension-update-permissions");
-Services.obs.addObserver(gXPInstallObserver, "webextension-install-notify");
-Services.obs.addObserver(
-  gXPInstallObserver,
-  "webextension-optional-permission-prompt"
-);
+
+/**
+ * This object is Thunderbird's version of the same object in
+ * browser/modules/ExtensionsUI.jsm
+ */
+var ExtensionsUI = {
+  sideloaded: new Set(),
+  updates: new Set(),
+  sideloadListener: null,
+  histogram: null,
+
+  pendingNotifications: new WeakMap(),
+
+  async init() {
+    this.histogram = Services.telemetry.getHistogramById(
+      "EXTENSION_INSTALL_PROMPT_RESULT"
+    );
+
+    Services.obs.addObserver(this, "webextension-permission-prompt");
+    Services.obs.addObserver(this, "webextension-update-permissions");
+    Services.obs.addObserver(this, "webextension-install-notify");
+    Services.obs.addObserver(this, "webextension-optional-permission-prompt");
+    Services.obs.addObserver(this, "webextension-defaultsearch-prompt");
+
+    this._checkForSideloaded();
+  },
+
+  async _checkForSideloaded() {
+    let sideloaded = await AddonManagerPrivate.getNewSideloads();
+
+    if (!sideloaded.length) {
+      // No new side-loads. We're done.
+      return;
+    }
+
+    // The ordering shouldn't matter, but tests depend on notifications
+    // happening in a specific order.
+    sideloaded.sort((a, b) => a.id.localeCompare(b.id));
+
+    if (WEBEXT_PERMISSION_PROMPTS) {
+      if (!this.sideloadListener) {
+        this.sideloadListener = {
+          onEnabled: addon => {
+            if (!this.sideloaded.has(addon)) {
+              return;
+            }
+
+            this.sideloaded.delete(addon);
+            this._updateNotifications();
+
+            if (this.sideloaded.size == 0) {
+              AddonManager.removeAddonListener(this.sideloadListener);
+              this.sideloadListener = null;
+            }
+          },
+        };
+        AddonManager.addAddonListener(this.sideloadListener);
+      }
+
+      for (let addon of sideloaded) {
+        this.sideloaded.add(addon);
+      }
+      this._updateNotifications();
+    }
+  },
+
+  _updateNotifications() {
+    if (this.sideloaded.size + this.updates.size == 0) {
+      AppMenuNotifications.removeNotification("addon-alert");
+    } else {
+      AppMenuNotifications.showBadgeOnlyNotification("addon-alert");
+    }
+    this.emit("change");
+  },
+
+  showSideloaded(tabbrowser, addon) {
+    addon.markAsSeen();
+    this.sideloaded.delete(addon);
+    this._updateNotifications();
+
+    let strings = this._buildStrings({
+      addon,
+      permissions: addon.userPermissions,
+      type: "sideload",
+    });
+
+    AMTelemetry.recordManageEvent(addon, "sideload_prompt", {
+      num_strings: strings.msgs.length,
+    });
+
+    this.showPermissionsPrompt(
+      tabbrowser,
+      strings,
+      addon.iconURL,
+      "sideload"
+    ).then(async answer => {
+      if (answer) {
+        await addon.enable();
+        this._updateNotifications();
+      }
+      this.emit("sideload-response");
+    });
+  },
+
+  showUpdate(browser, info) {
+    AMTelemetry.recordInstallEvent(info.install, {
+      step: "permissions_prompt",
+      num_strings: info.strings.msgs.length,
+    });
+
+    this.showPermissionsPrompt(
+      browser,
+      info.strings,
+      info.addon.iconURL,
+      "update"
+    ).then(answer => {
+      if (answer) {
+        info.resolve();
+      } else {
+        info.reject();
+      }
+      // At the moment, this prompt will re-appear next time we do an update
+      // check.  See bug 1332360 for proposal to avoid this.
+      this.updates.delete(info);
+      this._updateNotifications();
+    });
+  },
+
+  observe(subject, topic, data) {
+    if (topic == "webextension-permission-prompt") {
+      let { target, info } = subject.wrappedJSObject;
+
+      let { browser } = getTabBrowser(target);
+
+      // Dismiss the progress notification.  Note that this is bad if
+      // there are multiple simultaneous installs happening, see
+      // bug 1329884 for a longer explanation.
+      let progressNotification = getNotification("addon-progress", browser);
+      if (progressNotification) {
+        progressNotification.remove();
+      }
+
+      info.unsigned =
+        info.addon.signedState <= AddonManager.SIGNEDSTATE_MISSING;
+      if (
+        info.unsigned &&
+        Cu.isInAutomation &&
+        Services.prefs.getBoolPref("extensions.ui.ignoreUnsigned", false)
+      ) {
+        info.unsigned = false;
+      }
+
+      let strings = this._buildStrings(info);
+
+      // If this is an update with no promptable permissions, just apply it
+      if (info.type == "update" && !strings.msgs.length) {
+        info.resolve();
+        return;
+      }
+
+      let histkey;
+      if (info.type == "sideload") {
+        histkey = "sideload";
+      } else if (info.type == "update") {
+        histkey = "update";
+      } else if (info.source == "AMO") {
+        histkey = "installAmo";
+      } else if (info.source == "local") {
+        histkey = "installLocal";
+      } else {
+        histkey = "installWeb";
+      }
+
+      if (info.type == "sideload") {
+        AMTelemetry.recordManageEvent(info.addon, "sideload_prompt", {
+          num_strings: strings.msgs.length,
+        });
+      } else {
+        AMTelemetry.recordInstallEvent(info.install, {
+          step: "permissions_prompt",
+          num_strings: strings.msgs.length,
+        });
+      }
+
+      this.showPermissionsPrompt(browser, strings, info.icon, histkey).then(
+        answer => {
+          if (answer) {
+            info.resolve();
+          } else {
+            info.reject();
+          }
+        }
+      );
+    } else if (topic == "webextension-update-permissions") {
+      let info = subject.wrappedJSObject;
+      info.type = "update";
+      let strings = this._buildStrings(info);
+
+      // If we don't prompt for any new permissions, just apply it
+      if (!strings.msgs.length) {
+        info.resolve();
+        return;
+      }
+
+      let update = {
+        strings,
+        permissions: info.permissions,
+        install: info.install,
+        addon: info.addon,
+        resolve: info.resolve,
+        reject: info.reject,
+      };
+
+      this.updates.add(update);
+      this._updateNotifications();
+    } else if (topic == "webextension-install-notify") {
+      let { target, addon, callback } = subject.wrappedJSObject;
+      this.showInstallNotification(target, addon).then(() => {
+        if (callback) {
+          callback();
+        }
+      });
+    } else if (topic == "webextension-optional-permission-prompt") {
+      let {
+        browser,
+        name,
+        icon,
+        permissions,
+        resolve,
+      } = subject.wrappedJSObject;
+      let strings = this._buildStrings({
+        type: "optional",
+        addon: { name },
+        permissions,
+      });
 
-var ExtensionsUI = {
-  checkForSideloadedExtensions() {
-    let win = Services.wm.getMostRecentWindow("mail:3pane");
-    let tabmail = win.document.getElementById("tabmail");
-    gXPInstallObserver._checkForSideloaded(tabmail.selectedBrowser);
+      // If we don't have any promptable permissions, just proceed
+      if (!strings.msgs.length) {
+        resolve(true);
+        return;
+      }
+      resolve(this.showPermissionsPrompt(browser, strings, icon));
+    } else if (topic == "webextension-defaultsearch-prompt") {
+      let {
+        browser,
+        name,
+        icon,
+        respond,
+        currentEngine,
+        newEngine,
+      } = subject.wrappedJSObject;
+
+      let bundle = Services.strings.createBundle(ADDONS_PROPERTIES);
+
+      let strings = {};
+      strings.acceptText = bundle.GetStringFromName(
+        "webext.defaultSearchYes.label"
+      );
+      strings.acceptKey = bundle.GetStringFromName(
+        "webext.defaultSearchYes.accessKey"
+      );
+      strings.cancelText = bundle.GetStringFromName(
+        "webext.defaultSearchNo.label"
+      );
+      strings.cancelKey = bundle.GetStringFromName(
+        "webext.defaultSearchNo.accessKey"
+      );
+      strings.addonName = name;
+      strings.text = bundle.formatStringFromName(
+        "webext.defaultSearch.description",
+        ["<>", currentEngine, newEngine]
+      );
+
+      this.showDefaultSearchPrompt(browser, strings, icon).then(respond);
+    }
+  },
+
+  // Create a set of formatted strings for a permission prompt
+  _buildStrings(info) {
+    let bundle = Services.strings.createBundle(ADDONS_PROPERTIES);
+    let info2 = Object.assign({ appName: brandShortName }, info);
+
+    let strings = ExtensionData.formatPermissionStrings(info2, bundle, {
+      collapseOrigins: true,
+    });
+    // Silence the unsigned add-on warning. We can't stop
+    // formatPermissionStrings returning this string without changing it in
+    // addons.properties, and it might be wanted in future.
+    if (
+      strings.text == bundle.GetStringFromName("webextPerms.unsignedWarning")
+    ) {
+      strings.text = "";
+    }
+    strings.addonName = info.addon.name;
+    strings.learnMore = addonsBundle.getString("webextPerms.learnMore");
+    return strings;
+  },
+
+  async showPermissionsPrompt(target, strings, icon, histkey) {
+    let { browser, window } = getTabBrowser(target);
+
+    // Wait for any pending prompts in this window to complete before
+    // showing the next one.
+    let pending;
+    while ((pending = this.pendingNotifications.get(window))) {
+      await pending;
+    }
+
+    let promise = new Promise(resolve => {
+      function eventCallback(topic) {
+        let doc = window.document;
+        if (topic == "showing") {
+          let textEl = doc.getElementById("addon-webext-perm-text");
+          textEl.textContent = strings.text;
+          textEl.hidden = !strings.text;
+
+          let listIntroEl = doc.getElementById("addon-webext-perm-intro");
+          listIntroEl.textContent = strings.listIntro;
+          listIntroEl.hidden = !strings.msgs.length;
+
+          let listInfoEl = doc.getElementById("addon-webext-perm-info");
+          listInfoEl.textContent = strings.learnMore;
+          listInfoEl.href =
+            Services.urlFormatter.formatURLPref("app.support.baseURL") +
+            "extension-permissions";
+          listInfoEl.hidden = !strings.msgs.length;
+
+          let list = doc.getElementById("addon-webext-perm-list");
+          while (list.firstChild) {
+            list.firstChild.remove();
+          }
+
+          for (let msg of strings.msgs) {
+            let item = doc.createElementNS(HTML_NS, "li");
+            item.textContent = msg;
+            list.appendChild(item);
+          }
+        } else if (topic == "swapping") {
+          return true;
+        }
+        if (topic == "removed") {
+          Services.tm.dispatchToMainThread(() => {
+            resolve(false);
+          });
+        }
+        return false;
+      }
+
+      let popupOptions = {
+        hideClose: true,
+        popupIconURL: icon || DEFAULT_EXTENSION_ICON,
+        persistent: true,
+        eventCallback,
+        name: strings.addonName,
+        removeOnDismissal: true,
+      };
+
+      let action = {
+        label: strings.acceptText,
+        accessKey: strings.acceptKey,
+        callback: () => {
+          if (histkey) {
+            this.histogram.add(histkey + "Accepted");
+          }
+          resolve(true);
+        },
+      };
+      let secondaryActions = [
+        {
+          label: strings.cancelText,
+          accessKey: strings.cancelKey,
+          callback: () => {
+            if (histkey) {
+              this.histogram.add(histkey + "Rejected");
+            }
+            resolve(false);
+          },
+        },
+      ];
+
+      showNotification(
+        browser,
+        "addon-webext-permissions",
+        strings.header,
+        "addons-notification-icon",
+        action,
+        secondaryActions,
+        popupOptions
+      );
+    });
+
+    this.pendingNotifications.set(window, promise);
+    promise.finally(() => this.pendingNotifications.delete(window));
+    return promise;
+  },
+
+  showDefaultSearchPrompt(target, strings, icon) {
+    return new Promise(resolve => {
+      let popupOptions = {
+        hideClose: true,
+        popupIconURL: icon || DEFAULT_EXTENSION_ICON,
+        persistent: true,
+        removeOnDismissal: true,
+        eventCallback(topic) {
+          if (topic == "removed") {
+            resolve(false);
+          }
+        },
+        name: strings.addonName,
+      };
+
+      let action = {
+        label: strings.acceptText,
+        accessKey: strings.acceptKey,
+        disableHighlight: true,
+        callback: () => {
+          resolve(true);
+        },
+      };
+      let secondaryActions = [
+        {
+          label: strings.cancelText,
+          accessKey: strings.cancelKey,
+          callback: () => {
+            resolve(false);
+          },
+        },
+      ];
+
+      let { browser } = getTabBrowser(target);
+      showNotification(
+        browser,
+        "addon-webext-defaultsearch",
+        strings.text,
+        "addons-notification-icon",
+        action,
+        secondaryActions,
+        popupOptions
+      );
+    });
+  },
+
+  async showInstallNotification(target, addon) {
+    let { browser, window } = getTabBrowser(target);
+    let document = window.document;
+
+    let message = addonsBundle.getFormattedString("addonPostInstall.message1", [
+      "<>",
+      brandShortName,
+    ]);
+
+    let icon = DEFAULT_EXTENSION_ICON;
+    if (addon.isWebExtension) {
+      icon = AddonManager.getPreferredIconURL(addon, 32, window) || icon;
+    }
+
+    let options = {
+      hideClose: true,
+      timeout: Date.now() + 30000,
+      popupIconURL: icon,
+      name: addon.name,
+    };
+
+    let list = document.getElementById("addon-installed-list");
+    list.hidden = true;
+
+    showNotification(
+      browser,
+      "addon-installed",
+      message,
+      "addons-notification-icon",
+      {
+        label: addonsBundle.getString("addonPostInstall.okay.label"),
+        accessKey: addonsBundle.getString("addonPostInstall.okay.accesskey"),
+        callback: () => {},
+      },
+      null,
+      options
+    );
   },
 };
+
+EventEmitter.decorate(ExtensionsUI);
--- a/mail/components/MailGlue.jsm
+++ b/mail/components/MailGlue.jsm
@@ -258,17 +258,17 @@ MailGlue.prototype = {
         "resource:///modules/WindowsJumpLists.jsm"
       );
       WinTaskbarJumpList.startup();
     }
 
     const { ExtensionsUI } = ChromeUtils.import(
       "resource:///modules/ExtensionsUI.jsm"
     );
-    ExtensionsUI.checkForSideloadedExtensions();
+    ExtensionsUI.init();
 
     // If the application has been updated, look for any extensions that may
     // have been disabled by the update, and check for newer versions of those
     // extensions.
     let currentVersion = Services.appinfo.version;
     if (this.previousVersion != "0" && this.previousVersion != currentVersion) {
       let { AddonManager } = ChromeUtils.import(
         "resource://gre/modules/AddonManager.jsm"
--- a/mail/components/customizableui/content/panelUI.inc.xhtml
+++ b/mail/components/customizableui/content/panelUI.inc.xhtml
@@ -15,16 +15,24 @@
                   viewCacheId="appMenu-viewCache">
 
     <!-- Main Appmenu View -->
     <panelview id="appMenu-mainView" class="PanelUI-subView"
                descriptionheightworkaround="true">
       <vbox id="appMenu-mainViewItems"
             class="panel-subview-body">
         <vbox id="appMenu-addon-banners"/>
+        <toolbarbutton class="panel-banner-item"
+                       label-update-available="updateAvailable.panelUI.label"
+                       label-update-manual="updateManual.panelUI.label"
+                       label-update-unsupported="updateUnsupported.panelUI.label"
+                       label-update-restart="updateRestart.panelUI.label2"
+                       oncommand="PanelUI._onBannerItemSelected(event)"
+                       wrap="true"
+                       hidden="true"/>
         <toolbarbutton id="appmenu_new"
                        class="subviewbutton subviewbutton-iconic subviewbutton-nav"
                        label="&newMenu.label;"
                        closemenu="none"
                        oncommand="PanelUI.showSubView('appMenu-newView', this)"/>
         <toolbarbutton id="appmenu_msgAttachmentMenu"
                        class="subviewbutton subviewbutton-iconic subviewbutton-nav"
                        label="&openAttachmentListCmd.label;"
--- a/mail/components/customizableui/content/panelUI.js
+++ b/mail/components/customizableui/content/panelUI.js
@@ -6,24 +6,29 @@
   currentAttachments CustomizableUI ExtensionParent ExtensionSupport FullScreen
   getIconForAttachment goUpdateAttachmentCommands initAddonPrefsMenu
   initAppMenuPopup InitAppmenuViewBodyMenu InitAppMessageMenu
   InitAppmenuViewMessagesMenu InitAppFolderViewsMenu InitAppViewSortByMenu
   InitMessageTags InitRecentlyClosedTabsPopup InitViewFolderViewsMenu
   InitViewHeadersMenu InitViewLayoutStyleMenu MozXULElement msgWindow
   onViewToolbarsPopupShowing RefreshCustomViewsPopup RefreshTagsPopup
   RefreshViewPopup SanitizeAttachmentDisplayName Services ShortcutUtils
-  UpdateCharsetMenu updateEditUIVisibility UpdateFullZoomMenu XPCOMUtils */
+  StringBundle UpdateCharsetMenu updateEditUIVisibility UpdateFullZoomMenu
+  XPCOMUtils */
 
 ChromeUtils.defineModuleGetter(
   this,
   "AppMenuNotifications",
   "resource://gre/modules/AppMenuNotifications.jsm"
 );
-
+ChromeUtils.defineModuleGetter(
+  this,
+  "ExtensionsUI",
+  "resource:///modules/ExtensionsUI.jsm"
+);
 ChromeUtils.defineModuleGetter(
   this,
   "PanelMultiView",
   "resource:///modules/PanelMultiView.jsm"
 );
 
 // Needed for character encoding subviews.
 XPCOMUtils.defineLazyGetter(this, "gBundle", function() {
@@ -51,16 +56,17 @@ const PanelUI = {
    */
   get kElements() {
     return {
       mainView: "appMenu-mainView",
       multiView: "appMenu-multiView",
       menuButtonMail: "button-appmenu",
       menuButtonChat: "button-chat-appmenu",
       panel: "appMenu-popup",
+      addonNotificationContainer: "appMenu-addon-banners",
       navbar: "mail-bar3",
     };
   },
 
   /**
    * Used for the View / Text Encoding view.
    * Not ideal: copied from: mozilla-central/toolkit/modules/CharsetMenu.jsm
    * This set contains encodings that are in the Encoding Standard, except:
@@ -483,22 +489,16 @@ const PanelUI = {
       "toolbarseparator"
     );
   },
 
   get isReady() {
     return !!this._isReady;
   },
 
-  get isNotificationPanelOpen() {
-    let panelState = this.notificationPanel.state;
-
-    return panelState == "showing" || panelState == "open";
-  },
-
   /**
    * Registering the menu panel is done lazily for performance reasons. This
    * method is exposed so that CustomizationMode can force panel-readyness in the
    * event that customization mode is started before the panel has been opened
    * by the user.
    *
    * @param aCustomizing (optional) set to true if this was called while entering
    *        customization mode. If that's the case, we trust that customization
@@ -1066,164 +1066,80 @@ const PanelUI = {
       { x: tooltipId },
       "x",
       stringArgs
     );
     let quitButton = document.getElementById("PanelUI-quit");
     quitButton.setAttribute("tooltiptext", tooltipString);
   },
 
-  _hidePopup() {
-    if (this.isNotificationPanelOpen) {
-      this.notificationPanel.hidePopup();
-    }
-  },
-
   _updateNotifications(notificationsChanged) {
     let notifications = this._notifications;
     if (!notifications || !notifications.length) {
       if (notificationsChanged) {
         this._clearAllNotifications();
-        this._hidePopup();
       }
       return;
     }
 
     if (
       (window.fullScreen && FullScreen.navToolboxHidden) ||
       document.fullscreenElement
     ) {
-      this._hidePopup();
       return;
     }
 
     let doorhangers = notifications.filter(
       n => !n.dismissed && !n.options.badgeOnly
     );
 
     if (this.panel.state == "showing" || this.panel.state == "open") {
       // If the menu is already showing, then we need to dismiss all notifications
       // since we don't want their doorhangers competing for attention
       doorhangers.forEach(n => {
         n.dismissed = true;
         if (n.options.onDismissed) {
           n.options.onDismissed(window);
         }
       });
-      this._hidePopup();
       this._clearBadge();
       if (!notifications[0].options.badgeOnly) {
         this._showBannerItem(notifications[0]);
       }
     } else if (doorhangers.length > 0) {
       // Only show the doorhanger if the window is focused and not fullscreen
       if (
         (window.fullScreen && this.autoHideToolbarInFullScreen) ||
         Services.focus.activeWindow !== window
       ) {
-        this._hidePopup();
         this._showBadge(doorhangers[0]);
         this._showBannerItem(doorhangers[0]);
       } else {
         this._clearBadge();
-        this._showNotificationPanel(doorhangers[0]);
       }
     } else {
-      this._hidePopup();
       this._showBadge(notifications[0]);
       this._showBannerItem(notifications[0]);
     }
   },
 
-  _showNotificationPanel(notification) {
-    this._refreshNotificationPanel(notification);
-
-    if (this.isNotificationPanelOpen) {
-      return;
-    }
-
-    if (notification.options.beforeShowDoorhanger) {
-      notification.options.beforeShowDoorhanger(document);
-    }
-
-    let anchor = this._getPanelAnchor(this.menuButton);
-
-    this.notificationPanel.hidden = false;
-
-    // Insert Fluent files when needed before notification is opened
-    MozXULElement.insertFTLIfNeeded("branding/brand.ftl");
-    MozXULElement.insertFTLIfNeeded("browser/appMenuNotifications.ftl");
-
-    // After Fluent files are loaded into document replace data-lazy-l10n-ids with actual ones
-    document
-      .getElementById("appMenu-notification-popup")
-      .querySelectorAll("[data-lazy-l10n-id]")
-      .forEach(el => {
-        el.setAttribute("data-l10n-id", el.getAttribute("data-lazy-l10n-id"));
-        el.removeAttribute("data-lazy-l10n-id");
-      });
-
-    this.notificationPanel.openPopup(anchor, "bottomcenter topright");
-  },
-
-  _clearNotificationPanel() {
-    for (let popupnotification of this.notificationPanel.children) {
-      popupnotification.hidden = true;
-      popupnotification.notification = null;
-    }
-  },
-
   _clearAllNotifications() {
-    this._clearNotificationPanel();
     this._clearBadge();
     this._clearBannerItem();
   },
 
   _formatDescriptionMessage(n) {
     let text = {};
     let array = n.options.message.split("<>");
     text.start = array[0] || "";
     text.name = n.options.name || "";
     text.end = array[1] || "";
     return text;
   },
 
-  _refreshNotificationPanel(notification) {
-    this._clearNotificationPanel();
-
-    let popupnotificationID = this._getPopupId(notification);
-    let popupnotification = document.getElementById(popupnotificationID);
-
-    popupnotification.setAttribute("id", popupnotificationID);
-    popupnotification.setAttribute(
-      "buttoncommand",
-      "PanelUI._onNotificationButtonEvent(event, 'buttoncommand');"
-    );
-    popupnotification.setAttribute(
-      "secondarybuttoncommand",
-      "PanelUI._onNotificationButtonEvent(event, 'secondarybuttoncommand');"
-    );
-
-    if (notification.options.message) {
-      let desc = this._formatDescriptionMessage(notification);
-      popupnotification.setAttribute("label", desc.start);
-      popupnotification.setAttribute("name", desc.name);
-      popupnotification.setAttribute("endlabel", desc.end);
-    }
-    if (notification.options.onRefresh) {
-      notification.options.onRefresh(window);
-    }
-    if (notification.options.popupIconURL) {
-      popupnotification.setAttribute("icon", notification.options.popupIconURL);
-    }
-
-    popupnotification.notification = notification;
-    popupnotification.show();
-  },
-
   _showBadge(notification) {
     let badgeStatus = this._getBadgeStatus(notification);
     this.menuButton.setAttribute("badge-status", badgeStatus);
   },
 
   // "Banner item" here refers to an item in the hamburger panel menu. They will
   // typically show up as a colored row in the panel.
   _showBannerItem(notification) {
@@ -1331,8 +1247,100 @@ function getLocale() {
 }
 
 /**
  * Given a DOM node inside a <popupnotification>, return the parent <popupnotification>.
  */
 function getNotificationFromElement(aElement) {
   return aElement.closest("popupnotification");
 }
+
+/**
+ * This object is Thunderbird's version of the same object in
+ * browser/base/content/browser-addons.js.
+ */
+var gExtensionsNotifications = {
+  initialized: false,
+  init() {
+    this.updateAlerts();
+    this.boundUpdate = this.updateAlerts.bind(this);
+    ExtensionsUI.on("change", this.boundUpdate);
+    this.initialized = true;
+  },
+
+  uninit() {
+    // uninit() can race ahead of init() in some cases, if that happens,
+    // we have no handler to remove.
+    if (!this.initialized) {
+      return;
+    }
+    ExtensionsUI.off("change", this.boundUpdate);
+  },
+
+  _createAddonButton(text, icon, callback) {
+    let button = document.createXULElement("toolbarbutton");
+    button.setAttribute("label", text);
+    button.setAttribute("tooltiptext", text);
+    const DEFAULT_EXTENSION_ICON =
+      "chrome://mozapps/skin/extensions/extensionGeneric.svg";
+    button.setAttribute("image", icon || DEFAULT_EXTENSION_ICON);
+    button.className = "addon-banner-item";
+
+    button.addEventListener("command", callback);
+    PanelUI.addonNotificationContainer.appendChild(button);
+  },
+
+  updateAlerts() {
+    let tabmail = document.getElementById("tabmail");
+    let sideloaded = ExtensionsUI.sideloaded;
+    let updates = ExtensionsUI.updates;
+    let bundle = new StringBundle(
+      "chrome://messenger/locale/addons.properties"
+    );
+
+    let container = PanelUI.addonNotificationContainer;
+
+    while (container.firstChild) {
+      container.firstChild.remove();
+    }
+
+    let items = 0;
+    for (let update of updates) {
+      if (++items > 4) {
+        break;
+      }
+      let text = bundle.getFormattedString("webextPerms.updateMenuItem", [
+        update.addon.name,
+      ]);
+      this._createAddonButton(text, update.addon.iconURL, evt => {
+        ExtensionsUI.showUpdate(tabmail.selectedBrowser, update);
+      });
+    }
+
+    let appName;
+    for (let addon of sideloaded) {
+      if (++items > 4) {
+        break;
+      }
+      if (!appName) {
+        let brandBundle = document.getElementById("bundle_brand");
+        appName = brandBundle.getString("brandShortName");
+      }
+
+      let text = bundle.getFormattedString("webextPerms.sideloadMenuItem", [
+        addon.name,
+        appName,
+      ]);
+      this._createAddonButton(text, addon.iconURL, evt => {
+        // We need to hide the main menu manually because the toolbarbutton is
+        // removed immediately while processing this event, and PanelUI is
+        // unable to identify which panel should be closed automatically.
+        PanelUI.hide();
+        ExtensionsUI.showSideloaded(tabmail.selectedBrowser, addon);
+      });
+    }
+  },
+};
+
+addEventListener("load", () => gExtensionsNotifications.init(), { once: true });
+addEventListener("unload", () => gExtensionsNotifications.uninit(), {
+  once: true,
+});
--- a/mail/locales/en-US/chrome/messenger/addons.properties
+++ b/mail/locales/en-US/chrome/messenger/addons.properties
@@ -1,21 +1,45 @@
+# 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/.
+
 xpinstallPromptMessage=%S prevented this site from asking you to install software on your computer.
+# LOCALIZATION NOTE (xpinstallPromptMessage.header)
+# The string contains the hostname of the site the add-on is being installed from.
+xpinstallPromptMessage.header=Allow %S to install an add-on?
+xpinstallPromptMessage.message=You are attempting to install an add-on from %S. Make sure you trust this site before continuing.
+xpinstallPromptMessage.header.unknown=Allow an unknown site to install an add-on?
+xpinstallPromptMessage.message.unknown=You are attempting to install an add-on from an unknown site. Make sure you trust this site before continuing.
+xpinstallPromptMessage.learnMore=Learn more about installing add-ons safely
 xpinstallPromptMessage.dontAllow=Don’t Allow
 xpinstallPromptMessage.dontAllow.accesskey=D
-xpinstallPromptAllowButton=Allow
+xpinstallPromptMessage.neverAllow=Never Allow
+xpinstallPromptMessage.neverAllow.accesskey=N
+# Accessibility Note:
+# Be sure you do not choose an accesskey that is used elsewhere in the active context (e.g. main menu bar, submenu of the warning popup button)
+# See https://website-archive.mozilla.org/www.mozilla.org/access/access/keyboard/ for details
+xpinstallPromptMessage.install=Continue to Installation
+xpinstallPromptMessage.install.accesskey=C
+
 # Accessibility Note:
 # Be sure you do not choose an accesskey that is used elsewhere in the active context (e.g. main menu bar, submenu of the warning popup button)
 # See http://www.mozilla.org/access/keyboard/accesskey for details
-xpinstallPromptAllowButton.accesskey=A
 xpinstallDisabledMessageLocked=Software installation has been disabled by your system administrator.
 xpinstallDisabledMessage=Software installation is currently disabled. Click Enable and try again.
 xpinstallDisabledButton=Enable
 xpinstallDisabledButton.accesskey=n
 
+# LOCALIZATION NOTE (addonInstallBlockedByPolicy)
+# This message is shown when the installation of an add-on is blocked by
+# enterprise policy. %1$S is replaced by the name of the add-on.
+# %2$S is replaced by the ID of add-on. %3$S is a custom message that
+# the administration can add to the message.
+addonInstallBlockedByPolicy=%1$S (%2$S) is blocked by your system administrator.%3$S
+
 # LOCALIZATION NOTE (addonPostInstall.message1)
 # %1$S is replaced with the localized named of the extension that was
 # just installed.
 # %2$S is replaced with the localized name of the application.
 addonPostInstall.message1=%1$S has been added to %2$S.
 # LOCALIZATION NOTE (addonPostInstall.multiple.message1)
 # %1$S is replaced with the localized name of the application.
 addonPostInstall.multiple.message=These add-ons have been added to %1$S:
--- a/mail/themes/shared/customizableui/panelUI.css
+++ b/mail/themes/shared/customizableui/panelUI.css
@@ -1,12 +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/. */
 
+%filter substitution
+
 :root {
   --menu-panel-width: 22.35em;
   --wide-menu-panel-width: 29em;
   --standalone-subview-width: 30em;
   --panel-palette-icon-size: 16px;
 
   /* XXXgijs This is the ugliest bit of code I think I've ever written for Mozilla.
   Basically, the [extra 0.1px in the 1.1px] is there to avoid CSS rounding errors
@@ -1325,8 +1327,159 @@ toolbarpaletteitem[place="menu-panel"] >
 
 .subviewbutton.download:-moz-any([canShow],[canRetry]) > .action-button:not(:-moz-any([disabled],[open],:active)):-moz-any(:hover,:focus) {
   background-color: var(--arrowpanel-dimmed-further);
 }
 
 .subviewbutton.download:-moz-any([canShow],[canRetry]) > .action-button:not([disabled]):-moz-any([open],:hover:active) {
   background-color: var(--arrowpanel-dimmed-even-further);
 }
+
+%define menuPanelWidth 22.35em
+%define appmenuWarningBackgroundColor #FFEFBF
+%define appmenuWarningBackgroundColorHover #FFE8A2
+%define appmenuWarningBackgroundColorActive #FFE38F
+%define appmenuWarningColor black
+%define appmenuWarningBorderColor hsl(45,100%,77%)
+
+%define appmenuWarningBackgroundColorBrightText hsla(55,100%,50%,.1)
+%define appmenuWarningBackgroundColorHoverBrightText hsla(55,100%,50%,.15)
+%define appmenuWarningBackgroundColorActiveBrightText hsla(55,100%,50%,.2)
+%define appmenuWarningColorBrightText #F9F9FA
+
+.button-appmenu[badge-status] > .toolbarbutton-badge-stack > .toolbarbutton-badge {
+  display: -moz-box;
+  height: 10px;
+  width: 10px;
+  background-size: contain;
+  border: none;
+}
+
+.button-appmenu[badge-status="addon-alert"] > .toolbarbutton-badge-stack > .toolbarbutton-badge {
+  height: 13px;
+  background: url(chrome://browser/skin/warning.svg) center / contain no-repeat transparent;
+  box-shadow: none;
+  border-radius: 0;
+  /* Use the included fallbacks defined in the SVG file instead of inheriting from .toolbarbutton-1. */
+  -moz-context-properties: none;
+}
+
+.button-appmenu[badge-status] > .toolbarbutton-badge-stack > .toolbarbutton-badge:-moz-window-inactive {
+  filter: grayscale(100%);
+}
+
+#nav-bar[brighttext] .button-appmenu[badge-status="addon-alert"] > .toolbarbutton-badge-stack > .toolbarbutton-badge {
+  -moz-context-properties: fill, stroke;
+  fill: #FFE900;
+  stroke: transparent;
+}
+
+.addon-banner-item::after,
+.panel-banner-item::after {
+  content: "";
+  width: 16px;
+  height: 16px;
+  margin-inline-start: 10px;
+  margin-inline-end: 12px;
+  display: -moz-box;
+}
+
+.addon-banner-item {
+  background-color: @appmenuWarningBackgroundColor@;
+  color: @appmenuWarningColor@;
+  /* Force border to override `.addon-banner-item` selector below */
+  border-top: 1px solid @appmenuWarningBorderColor@ !important;
+  display: flex;
+  flex: 1 1 0%;
+  width: calc(@menuPanelWidth@ + 30px);
+  padding-inline-start: 15px;
+  border-inline-start-style: none;
+  -moz-image-region: rect(0, 16px, 16px, 0);
+}
+
+.addon-banner-item:last-child {
+  border-bottom: 1px solid @appmenuWarningBorderColor@;
+}
+
+.addon-banner-item:focus,
+.addon-banner-item:hover {
+  background-color: @appmenuWarningBackgroundColorHover@;
+}
+
+.addon-banner-item:hover:active {
+  background-color: @appmenuWarningBackgroundColorActive@;
+}
+
+.addon-banner-item > .toolbarbutton-icon {
+  width: 16px;
+  height: 16px;
+}
+
+.addon-banner-item::after {
+  background: url(chrome://browser/skin/warning.svg) no-repeat center;
+}
+
+:root[lwt-popup-brighttext] .addon-banner-item::after {
+  -moz-context-properties: fill, stroke;
+  fill: #FFE900;
+  stroke: transparent;
+}
+
+.addon-banner-item {
+  margin: 0;
+  padding: 11px 0;
+  box-sizing: border-box;
+  min-height: 40px;
+  -moz-appearance: none;
+  box-shadow: none;
+  border: none;
+  border-radius: 0;
+  transition: background-color;
+  -moz-box-orient: horizontal;
+}
+
+#appMenu-addon-banners:not(:empty) + .panel-banner-item {
+  /* Overlap the .addon-banner-item border so there's one border. */
+  margin-top: -1px;
+}
+
+#appMenu-addon-banners > .addon-banner-item {
+  padding-inline-start: 12px;
+}
+
+.addon-banner-item > .toolbarbutton-text,
+.panel-banner-item > .toolbarbutton-text {
+  margin: 0;
+  padding: 0 6px;
+  text-align: start;
+}
+
+.addon-banner-item > .toolbarbutton-icon,
+.panel-banner-item > .toolbarbutton-icon {
+  margin-inline-end: 0;
+}
+
+.addon-banner-item {
+  flex: 1;
+  padding-inline-start: 15px;
+  border-inline-start-style: none;
+}
+
+:root[lwt-popup-brighttext] .addon-banner-item {
+  color: @appmenuWarningColorBrightText@;
+  background: @appmenuWarningBackgroundColorBrightText@;
+  /* override `.addon-banner-item` border-top !important defined above */
+  border: 0 !important;
+}
+
+:root[lwt-popup-brighttext] .addon-banner-item:hover,
+:root[lwt-popup-brighttext] .addon-banner-item:focus {
+  background: @appmenuWarningBackgroundColorHoverBrightText@;
+}
+
+:root[lwt-popup-brighttext] .addon-banner-item:hover:active,
+:root[lwt-popup-brighttext] .addon-banner-item:focus:active {
+  background: @appmenuWarningBackgroundColorActiveBrightText@;
+}
+
+.addon-banner-item > .toolbarbutton-text {
+  padding-inline-start: 8px; /* See '.subviewbutton-iconic > .toolbarbutton-text' rule above. */
+}