summaryrefslogtreecommitdiffstats
path: root/comm/mail/components/extensions/parent
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/components/extensions/parent')
-rw-r--r--comm/mail/components/extensions/parent/.eslintrc.js81
-rw-r--r--comm/mail/components/extensions/parent/ext-accounts.js283
-rw-r--r--comm/mail/components/extensions/parent/ext-addressBook.js1587
-rw-r--r--comm/mail/components/extensions/parent/ext-browserAction.js329
-rw-r--r--comm/mail/components/extensions/parent/ext-chrome-settings-overrides.js365
-rw-r--r--comm/mail/components/extensions/parent/ext-cloudFile.js804
-rw-r--r--comm/mail/components/extensions/parent/ext-commands.js103
-rw-r--r--comm/mail/components/extensions/parent/ext-compose.js1703
-rw-r--r--comm/mail/components/extensions/parent/ext-composeAction.js154
-rw-r--r--comm/mail/components/extensions/parent/ext-extensionScripts.js185
-rw-r--r--comm/mail/components/extensions/parent/ext-folders.js675
-rw-r--r--comm/mail/components/extensions/parent/ext-identities.js360
-rw-r--r--comm/mail/components/extensions/parent/ext-mail.js2883
-rw-r--r--comm/mail/components/extensions/parent/ext-mailTabs.js485
-rw-r--r--comm/mail/components/extensions/parent/ext-menus.js1544
-rw-r--r--comm/mail/components/extensions/parent/ext-messageDisplay.js348
-rw-r--r--comm/mail/components/extensions/parent/ext-messageDisplayAction.js251
-rw-r--r--comm/mail/components/extensions/parent/ext-messages.js1563
-rw-r--r--comm/mail/components/extensions/parent/ext-sessions.js62
-rw-r--r--comm/mail/components/extensions/parent/ext-spaces.js364
-rw-r--r--comm/mail/components/extensions/parent/ext-spacesToolbar.js308
-rw-r--r--comm/mail/components/extensions/parent/ext-tabs.js822
-rw-r--r--comm/mail/components/extensions/parent/ext-theme.js543
-rw-r--r--comm/mail/components/extensions/parent/ext-windows.js555
24 files changed, 16357 insertions, 0 deletions
diff --git a/comm/mail/components/extensions/parent/.eslintrc.js b/comm/mail/components/extensions/parent/.eslintrc.js
new file mode 100644
index 0000000000..73279358eb
--- /dev/null
+++ b/comm/mail/components/extensions/parent/.eslintrc.js
@@ -0,0 +1,81 @@
+"use strict";
+
+module.exports = {
+ globals: {
+ // These are defined in the WebExtension script scopes by ExtensionCommon.jsm.
+ // From toolkit/components/extensions/.eslintrc.js.
+ ExtensionAPI: true,
+ ExtensionAPIPersistent: true,
+ ExtensionCommon: true,
+ ExtensionUtils: true,
+ extensions: true,
+ global: true,
+ Services: true,
+
+ // From toolkit/components/extensions/parent/.eslintrc.js.
+ CONTAINER_STORE: true,
+ DEFAULT_STORE: true,
+ EventEmitter: true,
+ EventManager: true,
+ InputEventManager: true,
+ PRIVATE_STORE: true,
+ TabBase: true,
+ TabManagerBase: true,
+ TabTrackerBase: true,
+ WindowBase: true,
+ WindowManagerBase: true,
+ WindowTrackerBase: true,
+ getContainerForCookieStoreId: true,
+ getUserContextIdForCookieStoreId: true,
+ getCookieStoreIdForOriginAttributes: true,
+ getCookieStoreIdForContainer: true,
+ getCookieStoreIdForTab: true,
+ isContainerCookieStoreId: true,
+ isDefaultCookieStoreId: true,
+ isPrivateCookieStoreId: true,
+ isValidCookieStoreId: true,
+
+ // These are defined in ext-mail.js.
+ ADDRESS_BOOK_WINDOW_URI: true,
+ COMPOSE_WINDOW_URI: true,
+ MAIN_WINDOW_URI: true,
+ MESSAGE_WINDOW_URI: true,
+ MESSAGE_PROTOCOLS: true,
+ NOTIFICATION_COLLAPSE_TIME: true,
+ ExtensionError: true,
+ Tab: true,
+ TabmailTab: true,
+ Window: true,
+ TabmailWindow: true,
+ clickModifiersFromEvent: true,
+ convertFolder: true,
+ convertAccount: true,
+ traverseSubfolders: true,
+ convertMailIdentity: true,
+ convertMessage: true,
+ folderPathToURI: true,
+ folderURIToPath: true,
+ getNormalWindowReady: true,
+ getRealFileForFile: true,
+ getTabBrowser: true,
+ getTabTabmail: true,
+ getTabWindow: true,
+ messageListTracker: true,
+ messageTracker: true,
+ nsDummyMsgHeader: true,
+ spaceTracker: true,
+ tabGetSender: true,
+ tabTracker: true,
+ windowTracker: true,
+
+ // ext-browserAction.js
+ browserActionFor: true,
+ },
+ rules: {
+ // From toolkit/components/extensions/.eslintrc.js.
+ // Disable reject-importGlobalProperties because we don't want to include
+ // these in the sandbox directly as that would potentially mean the
+ // imported properties would be instantiated up-front rather than lazily.
+ "mozilla/reject-importGlobalProperties": "off",
+ },
+};
diff --git a/comm/mail/components/extensions/parent/ext-accounts.js b/comm/mail/components/extensions/parent/ext-accounts.js
new file mode 100644
index 0000000000..2388f896c7
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-accounts.js
@@ -0,0 +1,283 @@
+/* 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/. */
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailServices",
+ "resource:///modules/MailServices.jsm"
+);
+
+/**
+ * @implements {nsIObserver}
+ * @implements {nsIMsgFolderListener}
+ */
+var accountsTracker = new (class extends EventEmitter {
+ constructor() {
+ super();
+ this.listenerCount = 0;
+ this.monitoredAccounts = new Map();
+
+ // Keep track of accounts data monitored for changes.
+ for (let nativeAccount of MailServices.accounts.accounts) {
+ this.monitoredAccounts.set(
+ nativeAccount.key,
+ this.getMonitoredProperties(nativeAccount)
+ );
+ }
+ }
+
+ getMonitoredProperties(nativeAccount) {
+ return {
+ name: nativeAccount.incomingServer.prettyName,
+ defaultIdentityKey: nativeAccount.defaultIdentity?.key,
+ };
+ }
+
+ getChangedMonitoredProperty(nativeAccount, propertyName) {
+ if (!nativeAccount || !this.monitoredAccounts.has(nativeAccount.key)) {
+ return false;
+ }
+ let values = this.monitoredAccounts.get(nativeAccount.key);
+ let propertyValue =
+ this.getMonitoredProperties(nativeAccount)[propertyName];
+ if (propertyValue && values[propertyName] != propertyValue) {
+ values[propertyName] = propertyValue;
+ this.monitoredAccounts.set(nativeAccount.key, values);
+ return propertyValue;
+ }
+ return false;
+ }
+
+ incrementListeners() {
+ this.listenerCount++;
+ if (this.listenerCount == 1) {
+ // nsIMsgFolderListener
+ MailServices.mfn.addListener(this, MailServices.mfn.folderAdded);
+ Services.prefs.addObserver("mail.server.", this);
+ Services.prefs.addObserver("mail.account.", this);
+ for (let topic of this._notifications) {
+ Services.obs.addObserver(this, topic);
+ }
+ }
+ }
+ decrementListeners() {
+ this.listenerCount--;
+ if (this.listenerCount == 0) {
+ MailServices.mfn.removeListener(this);
+ Services.prefs.removeObserver("mail.server.", this);
+ Services.prefs.removeObserver("mail.account.", this);
+ for (let topic of this._notifications) {
+ Services.obs.removeObserver(this, topic);
+ }
+ }
+ }
+
+ // nsIMsgFolderListener
+ folderAdded(folder) {
+ // If the account of this folder is unknown, it is new and this is the
+ // initial root folder after the account has been created.
+ let server = folder.server;
+ let nativeAccount = MailServices.accounts.FindAccountForServer(server);
+ if (nativeAccount && !this.monitoredAccounts.has(nativeAccount.key)) {
+ this.monitoredAccounts.set(
+ nativeAccount.key,
+ this.getMonitoredProperties(nativeAccount)
+ );
+ let account = convertAccount(nativeAccount, false);
+ this.emit("account-added", nativeAccount.key, account);
+ }
+ }
+
+ // nsIObserver
+ _notifications = ["message-account-removed"];
+
+ async observe(subject, topic, data) {
+ switch (topic) {
+ case "nsPref:changed":
+ {
+ let [, type, key, property] = data.split(".");
+
+ if (type == "server" && property == "name") {
+ let server;
+ try {
+ server = MailServices.accounts.getIncomingServer(key);
+ } catch (ex) {
+ // Fails for servers being removed.
+ return;
+ }
+ let nativeAccount =
+ MailServices.accounts.FindAccountForServer(server);
+
+ let name = this.getChangedMonitoredProperty(nativeAccount, "name");
+ if (name) {
+ this.emit("account-updated", nativeAccount.key, {
+ id: nativeAccount.key,
+ name,
+ });
+ }
+ }
+
+ if (type == "account" && property == "identities") {
+ let nativeAccount = MailServices.accounts.getAccount(key);
+
+ let defaultIdentityKey = this.getChangedMonitoredProperty(
+ nativeAccount,
+ "defaultIdentityKey"
+ );
+ if (defaultIdentityKey) {
+ this.emit("account-updated", nativeAccount.key, {
+ id: nativeAccount.key,
+ defaultIdentity: convertMailIdentity(
+ nativeAccount,
+ nativeAccount.defaultIdentity
+ ),
+ });
+ }
+ }
+ }
+ break;
+
+ case "message-account-removed":
+ if (this.monitoredAccounts.has(data)) {
+ this.monitoredAccounts.delete(data);
+ this.emit("account-removed", data);
+ }
+ break;
+ }
+ }
+})();
+
+this.accounts = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onCreated({ context, fire }) {
+ async function listener(_event, key, account) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(key, account);
+ }
+ accountsTracker.on("account-added", listener);
+ return {
+ unregister: () => {
+ accountsTracker.off("account-added", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onUpdated({ context, fire }) {
+ async function listener(_event, key, changedValues) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(key, changedValues);
+ }
+ accountsTracker.on("account-updated", listener);
+ return {
+ unregister: () => {
+ accountsTracker.off("account-updated", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onDeleted({ context, fire }) {
+ async function listener(_event, key) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(key);
+ }
+ accountsTracker.on("account-removed", listener);
+ return {
+ unregister: () => {
+ accountsTracker.off("account-removed", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ constructor(...args) {
+ super(...args);
+ accountsTracker.incrementListeners();
+ }
+
+ onShutdown() {
+ accountsTracker.decrementListeners();
+ }
+
+ getAPI(context) {
+ return {
+ accounts: {
+ async list(includeFolders) {
+ let accounts = [];
+ for (let account of MailServices.accounts.accounts) {
+ account = convertAccount(account, includeFolders);
+ if (account) {
+ accounts.push(account);
+ }
+ }
+ return accounts;
+ },
+ async get(accountId, includeFolders) {
+ let account = MailServices.accounts.getAccount(accountId);
+ return convertAccount(account, includeFolders);
+ },
+ async getDefault(includeFolders) {
+ let account = MailServices.accounts.defaultAccount;
+ return convertAccount(account, includeFolders);
+ },
+ async getDefaultIdentity(accountId) {
+ let account = MailServices.accounts.getAccount(accountId);
+ return convertMailIdentity(account, account?.defaultIdentity);
+ },
+ async setDefaultIdentity(accountId, identityId) {
+ let account = MailServices.accounts.getAccount(accountId);
+ if (!account) {
+ throw new ExtensionError(`Account not found: ${accountId}`);
+ }
+ for (let identity of account.identities) {
+ if (identity.key == identityId) {
+ account.defaultIdentity = identity;
+ return;
+ }
+ }
+ throw new ExtensionError(
+ `Identity ${identityId} not found for ${accountId}`
+ );
+ },
+ onCreated: new EventManager({
+ context,
+ module: "accounts",
+ event: "onCreated",
+ extensionApi: this,
+ }).api(),
+ onUpdated: new EventManager({
+ context,
+ module: "accounts",
+ event: "onUpdated",
+ extensionApi: this,
+ }).api(),
+ onDeleted: new EventManager({
+ context,
+ module: "accounts",
+ event: "onDeleted",
+ extensionApi: this,
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-addressBook.js b/comm/mail/components/extensions/parent/ext-addressBook.js
new file mode 100644
index 0000000000..14b0ce8cd0
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-addressBook.js
@@ -0,0 +1,1587 @@
+/* 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/. */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+var { AddrBookDirectory } = ChromeUtils.import(
+ "resource:///modules/AddrBookDirectory.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["fetch", "File", "FileReader"]);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ newUID: "resource:///modules/AddrBookUtils.jsm",
+ AddrBookCard: "resource:///modules/AddrBookCard.jsm",
+ BANISHED_PROPERTIES: "resource:///modules/VCardUtils.jsm",
+ VCardProperties: "resource:///modules/VCardUtils.jsm",
+ VCardPropertyEntry: "resource:///modules/VCardUtils.jsm",
+ VCardUtils: "resource:///modules/VCardUtils.jsm",
+});
+
+// nsIAbCard.idl contains a list of properties that Thunderbird uses. Extensions are not
+// restricted to using only these properties, but the following properties cannot
+// be modified by an extension.
+const hiddenProperties = [
+ "DbRowID",
+ "LowercasePrimaryEmail",
+ "LastModifiedDate",
+ "PopularityIndex",
+ "RecordKey",
+ "UID",
+ "_etag",
+ "_href",
+ "_vCard",
+ "vCard",
+ "PhotoName",
+ "PhotoURL",
+ "PhotoType",
+];
+
+/**
+ * Reads a DOM File and returns a Promise for its dataUrl.
+ *
+ * @param {File} file
+ * @returns {string}
+ */
+function getDataUrl(file) {
+ return new Promise((resolve, reject) => {
+ var reader = new FileReader();
+ reader.readAsDataURL(file);
+ reader.onload = function () {
+ resolve(reader.result);
+ };
+ reader.onerror = function (error) {
+ reject(new ExtensionError(error));
+ };
+ });
+}
+
+/**
+ * Returns the image type of the given contentType string, or throws if the
+ * contentType is not an image type supported by the address book.
+ *
+ * @param {string} contentType - The contentType of a photo.
+ * @returns {string} - Either "png" or "jpeg". Throws otherwise.
+ */
+function getImageType(contentType) {
+ let typeParts = contentType.toLowerCase().split("/");
+ if (typeParts[0] != "image" || !["jpeg", "png"].includes(typeParts[1])) {
+ throw new ExtensionError(`Unsupported image format: ${contentType}`);
+ }
+ return typeParts[1];
+}
+
+/**
+ * Adds a PHOTO VCardPropertyEntry for the given photo file.
+ *
+ * @param {VCardProperties} vCardProperties
+ * @param {File} photoFile
+ * @returns {VCardPropertyEntry}
+ */
+async function addVCardPhotoEntry(vCardProperties, photoFile) {
+ let dataUrl = await getDataUrl(photoFile);
+ if (vCardProperties.getFirstValue("version") == "4.0") {
+ vCardProperties.addEntry(
+ new VCardPropertyEntry("photo", {}, "url", dataUrl)
+ );
+ } else {
+ // If vCard version is not 4.0, default to 3.0.
+ vCardProperties.addEntry(
+ new VCardPropertyEntry(
+ "photo",
+ { encoding: "B", type: getImageType(photoFile.type).toUpperCase() },
+ "binary",
+ dataUrl.substring(dataUrl.indexOf(",") + 1)
+ )
+ );
+ }
+}
+
+/**
+ * Returns a DOM File object for the contact photo of the given contact.
+ *
+ * @param {string} id - The id of the contact
+ * @returns {File} The photo of the contact, or null.
+ */
+async function getPhotoFile(id) {
+ let { item } = addressBookCache.findContactById(id);
+ let photoUrl = item.photoURL;
+ if (!photoUrl) {
+ return null;
+ }
+
+ try {
+ if (photoUrl.startsWith("file://")) {
+ let realFile = Services.io
+ .newURI(photoUrl)
+ .QueryInterface(Ci.nsIFileURL).file;
+ let file = await File.createFromNsIFile(realFile);
+ let type = getImageType(file.type);
+ // Clone the File object to be able to give it the correct name, matching
+ // the dataUrl/webUrl code path below.
+ return new File([file], `${id}.${type}`, { type: `image/${type}` });
+ }
+
+ // Retrieve dataUrls or webUrls.
+ let result = await fetch(photoUrl);
+ let type = getImageType(result.headers.get("content-type"));
+ let blob = await result.blob();
+ return new File([blob], `${id}.${type}`, { type: `image/${type}` });
+ } catch (ex) {
+ console.error(`Failed to read photo information for ${id}: ` + ex);
+ }
+
+ return null;
+}
+
+/**
+ * Sets the provided file as the primary photo of the given contact.
+ *
+ * @param {string} id - The id of the contact
+ * @param {File} file - The new photo
+ */
+async function setPhotoFile(id, file) {
+ let node = addressBookCache.findContactById(id);
+ let vCardProperties = vCardPropertiesFromCard(node.item);
+
+ try {
+ let type = getImageType(file.type);
+
+ // If the contact already has a photoUrl, replace it with the same url type.
+ // Otherwise save the photo as a local file, except for CardDAV contacts.
+ let photoUrl = node.item.photoURL;
+ let parentNode = addressBookCache.findAddressBookById(node.parentId);
+ let useFile = photoUrl
+ ? photoUrl.startsWith("file://")
+ : parentNode.item.dirType != Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE;
+
+ if (useFile) {
+ let oldPhotoFile;
+ if (photoUrl) {
+ try {
+ oldPhotoFile = Services.io
+ .newURI(photoUrl)
+ .QueryInterface(Ci.nsIFileURL).file;
+ } catch (ex) {
+ console.error(`Ignoring invalid photoUrl ${photoUrl}: ` + ex);
+ }
+ }
+ let pathPhotoFile = await IOUtils.createUniqueFile(
+ PathUtils.join(PathUtils.profileDir, "Photos"),
+ `${id}.${type}`,
+ 0o600
+ );
+
+ if (file.mozFullPath) {
+ // The file object was created by selecting a real file through a file
+ // picker and is directly linked to a local file. Do a low level copy.
+ await IOUtils.copy(file.mozFullPath, pathPhotoFile);
+ } else {
+ // The file object is a data blob. Dump it into a real file.
+ let buffer = await file.arrayBuffer();
+ await IOUtils.write(pathPhotoFile, new Uint8Array(buffer));
+ }
+
+ // Set the PhotoName.
+ node.item.setProperty("PhotoName", PathUtils.filename(pathPhotoFile));
+
+ // Delete the old photo file.
+ if (oldPhotoFile?.exists()) {
+ try {
+ await IOUtils.remove(oldPhotoFile.path);
+ } catch (ex) {
+ console.error(`Failed to delete old photo file for ${id}: ` + ex);
+ }
+ }
+ } else {
+ // Follow the UI and replace the entire entry.
+ vCardProperties.clearValues("photo");
+ await addVCardPhotoEntry(vCardProperties, file);
+ }
+ parentNode.item.modifyCard(node.item);
+ } catch (ex) {
+ throw new ExtensionError(
+ `Failed to read new photo information for ${id}: ` + ex
+ );
+ }
+}
+
+/**
+ * Gets the VCardProperties of the given card either directly or by reconstructing
+ * from a set of flat standard properties.
+ *
+ * @param {nsIAbCard/AddrBookCard} card
+ * @returns {VCardProperties}
+ */
+function vCardPropertiesFromCard(card) {
+ if (card.supportsVCard) {
+ return card.vCardProperties;
+ }
+ return VCardProperties.fromPropertyMap(
+ new Map(Array.from(card.properties, p => [p.name, p.value]))
+ );
+}
+
+/**
+ * Creates a new AddrBookCard from a set of flat standard properties.
+ *
+ * @param {ContactProperties} properties - a key/value properties object
+ * @param {string} uid - optional UID for the card
+ * @returns {AddrBookCard}
+ */
+function flatPropertiesToAbCard(properties, uid) {
+ // Do not use VCardUtils.propertyMapToVCard().
+ let vCard = VCardProperties.fromPropertyMap(
+ new Map(Object.entries(properties))
+ ).toVCard();
+ return VCardUtils.vCardToAbCard(vCard, uid);
+}
+
+/**
+ * Checks if the given property is a custom contact property, which can be exposed
+ * to WebExtensions.
+ *
+ * @param {string} name - property name
+ * @returns {boolean}
+ */
+function isCustomProperty(name) {
+ return (
+ !hiddenProperties.includes(name) &&
+ !BANISHED_PROPERTIES.includes(name) &&
+ name.match(/^\w+$/)
+ );
+}
+
+/**
+ * Adds the provided originalProperties to the card, adjusted by the changes
+ * given in updateProperties. All banished properties are skipped and the updated
+ * properties must be valid according to isCustomProperty().
+ *
+ * @param {AddrBookCard} card - a card to receive the provided properties
+ * @param {ContactProperties} updateProperties - a key/value object with properties
+ * to update the provided originalProperties
+ * @param {nsIProperties} originalProperties - properties to be cloned onto
+ * the provided card
+ */
+function addProperties(card, updateProperties, originalProperties) {
+ let updates = Object.entries(updateProperties).filter(e =>
+ isCustomProperty(e[0])
+ );
+ let mergedProperties = originalProperties
+ ? new Map([
+ ...Array.from(originalProperties, p => [p.name, p.value]),
+ ...updates,
+ ])
+ : new Map(updates);
+
+ for (let [name, value] of mergedProperties) {
+ if (
+ !BANISHED_PROPERTIES.includes(name) &&
+ value != "" &&
+ value != null &&
+ value != undefined
+ ) {
+ card.setProperty(name, value);
+ }
+ }
+}
+
+/**
+ * Address book that supports finding cards only for a search (like LDAP).
+ *
+ * @implements {nsIAbDirectory}
+ */
+class ExtSearchBook extends AddrBookDirectory {
+ constructor(fire, context, args = {}) {
+ super();
+ this.fire = fire;
+ this._readOnly = true;
+ this._isSecure = Boolean(args.isSecure);
+ this._dirName = String(args.addressBookName ?? context.extension.name);
+ this._fileName = "";
+ this._uid = String(args.id ?? newUID());
+ this._uri = "searchaddr://" + this.UID;
+ this.lastModifiedDate = 0;
+ this.isMailList = false;
+ this.listNickName = "";
+ this.description = "";
+ this._dirPrefId = "";
+ }
+ /**
+ * @see {AddrBookDirectory}
+ */
+ get lists() {
+ return new Map();
+ }
+ /**
+ * @see {AddrBookDirectory}
+ */
+ get cards() {
+ return new Map();
+ }
+ // nsIAbDirectory
+ get isRemote() {
+ return true;
+ }
+ get isSecure() {
+ return this._isSecure;
+ }
+ getCardFromProperty(aProperty, aValue, aCaseSensitive) {
+ return null;
+ }
+ getCardsFromProperty(aProperty, aValue, aCaseSensitive) {
+ return [];
+ }
+ get dirType() {
+ return Ci.nsIAbManager.ASYNC_DIRECTORY_TYPE;
+ }
+ get position() {
+ return 0;
+ }
+ get childCardCount() {
+ return 0;
+ }
+ useForAutocomplete(aIdentityKey) {
+ // AddrBookDirectory defaults to true
+ return false;
+ }
+ get supportsMailingLists() {
+ return false;
+ }
+ setLocalizedStringValue(aName, aValue) {}
+ async search(aQuery, aSearchString, aListener) {
+ try {
+ if (this.fire.wakeup) {
+ await this.fire.wakeup();
+ }
+ let { results, isCompleteResult } = await this.fire.async(
+ await addressBookCache.convert(
+ addressBookCache.addressBooks.get(this.UID)
+ ),
+ aSearchString,
+ aQuery
+ );
+ for (let resultData of results) {
+ let card;
+ // A specified vCard is winning over any individual standard property.
+ if (resultData.vCard) {
+ try {
+ card = VCardUtils.vCardToAbCard(resultData.vCard);
+ } catch (ex) {
+ throw new ExtensionError(
+ `Invalid vCard data: ${resultData.vCard}.`
+ );
+ }
+ } else {
+ card = flatPropertiesToAbCard(resultData);
+ }
+ // Add custom properties to the property bag.
+ addProperties(card, resultData);
+ card.directoryUID = this.UID;
+ aListener.onSearchFoundCard(card);
+ }
+ aListener.onSearchFinished(Cr.NS_OK, isCompleteResult, null, "");
+ } catch (ex) {
+ aListener.onSearchFinished(
+ ex.result || Cr.NS_ERROR_FAILURE,
+ true,
+ null,
+ ""
+ );
+ }
+ }
+}
+
+/**
+ * Cache of items in the address book "tree".
+ *
+ * @implements {nsIObserver}
+ */
+var addressBookCache = new (class extends EventEmitter {
+ constructor() {
+ super();
+ this.listenerCount = 0;
+ this.flush();
+ }
+ _makeContactNode(contact, parent) {
+ contact.QueryInterface(Ci.nsIAbCard);
+ return {
+ id: contact.UID,
+ parentId: parent.UID,
+ type: "contact",
+ item: contact,
+ };
+ }
+ _makeDirectoryNode(directory, parent = null) {
+ directory.QueryInterface(Ci.nsIAbDirectory);
+ let node = {
+ id: directory.UID,
+ type: directory.isMailList ? "mailingList" : "addressBook",
+ item: directory,
+ };
+ if (parent) {
+ node.parentId = parent.UID;
+ }
+ return node;
+ }
+ _populateListContacts(mailingList) {
+ mailingList.contacts = new Map();
+ for (let contact of mailingList.item.childCards) {
+ let newNode = this._makeContactNode(contact, mailingList.item);
+ mailingList.contacts.set(newNode.id, newNode);
+ }
+ }
+ getListContacts(mailingList) {
+ if (!mailingList.contacts) {
+ this._populateListContacts(mailingList);
+ }
+ return [...mailingList.contacts.values()];
+ }
+ _populateContacts(addressBook) {
+ addressBook.contacts = new Map();
+ for (let contact of addressBook.item.childCards) {
+ if (!contact.isMailList) {
+ let newNode = this._makeContactNode(contact, addressBook.item);
+ this._contacts.set(newNode.id, newNode);
+ addressBook.contacts.set(newNode.id, newNode);
+ }
+ }
+ }
+ getContacts(addressBook) {
+ if (!addressBook.contacts) {
+ this._populateContacts(addressBook);
+ }
+ return [...addressBook.contacts.values()];
+ }
+ _populateMailingLists(parent) {
+ parent.mailingLists = new Map();
+ for (let mailingList of parent.item.childNodes) {
+ let newNode = this._makeDirectoryNode(mailingList, parent.item);
+ this._mailingLists.set(newNode.id, newNode);
+ parent.mailingLists.set(newNode.id, newNode);
+ }
+ }
+ getMailingLists(parent) {
+ if (!parent.mailingLists) {
+ this._populateMailingLists(parent);
+ }
+ return [...parent.mailingLists.values()];
+ }
+ get addressBooks() {
+ if (!this._addressBooks) {
+ this._addressBooks = new Map();
+ for (let tld of MailServices.ab.directories) {
+ this._addressBooks.set(tld.UID, this._makeDirectoryNode(tld));
+ }
+ }
+ return this._addressBooks;
+ }
+ flush() {
+ this._contacts = new Map();
+ this._mailingLists = new Map();
+ this._addressBooks = null;
+ }
+ findAddressBookById(id) {
+ let addressBook = this.addressBooks.get(id);
+ if (addressBook) {
+ return addressBook;
+ }
+ throw new ExtensionUtils.ExtensionError(
+ `addressBook with id=${id} could not be found.`
+ );
+ }
+ findMailingListById(id) {
+ if (this._mailingLists.has(id)) {
+ return this._mailingLists.get(id);
+ }
+ for (let addressBook of this.addressBooks.values()) {
+ if (!addressBook.mailingLists) {
+ this._populateMailingLists(addressBook);
+ if (addressBook.mailingLists.has(id)) {
+ return addressBook.mailingLists.get(id);
+ }
+ }
+ }
+ throw new ExtensionUtils.ExtensionError(
+ `mailingList with id=${id} could not be found.`
+ );
+ }
+ findContactById(id, bookHint) {
+ if (this._contacts.has(id)) {
+ return this._contacts.get(id);
+ }
+ if (bookHint && !bookHint.contacts) {
+ this._populateContacts(bookHint);
+ if (bookHint.contacts.has(id)) {
+ return bookHint.contacts.get(id);
+ }
+ }
+ for (let addressBook of this.addressBooks.values()) {
+ if (!addressBook.contacts) {
+ this._populateContacts(addressBook);
+ if (addressBook.contacts.has(id)) {
+ return addressBook.contacts.get(id);
+ }
+ }
+ }
+ throw new ExtensionUtils.ExtensionError(
+ `contact with id=${id} could not be found.`
+ );
+ }
+ async convert(node, complete) {
+ if (node === null) {
+ return node;
+ }
+ if (Array.isArray(node)) {
+ let cards = await Promise.allSettled(
+ node.map(i => this.convert(i, complete))
+ );
+ return cards.filter(card => card.value).map(card => card.value);
+ }
+
+ let copy = {};
+ for (let key of ["id", "parentId", "type"]) {
+ if (key in node) {
+ copy[key] = node[key];
+ }
+ }
+
+ if (complete) {
+ if (node.type == "addressBook") {
+ copy.mailingLists = await this.convert(
+ this.getMailingLists(node),
+ true
+ );
+ copy.contacts = await this.convert(this.getContacts(node), true);
+ }
+ if (node.type == "mailingList") {
+ copy.contacts = await this.convert(this.getListContacts(node), true);
+ }
+ }
+
+ switch (node.type) {
+ case "addressBook":
+ copy.name = node.item.dirName;
+ copy.readOnly = node.item.readOnly;
+ copy.remote = node.item.isRemote;
+ break;
+ case "contact": {
+ // Clone the vCardProperties of this contact, so we can manipulate them
+ // for the WebExtension, but do not actually change the stored data.
+ let vCardProperties = vCardPropertiesFromCard(node.item).clone();
+ copy.properties = {};
+
+ // Build a flat property list from vCardProperties.
+ for (let [name, value] of vCardProperties.toPropertyMap()) {
+ copy.properties[name] = "" + value;
+ }
+
+ // Return all other exposed properties stored in the nodes property bag.
+ for (let property of Array.from(node.item.properties).filter(e =>
+ isCustomProperty(e.name)
+ )) {
+ copy.properties[property.name] = "" + property.value;
+ }
+
+ // If this card has no photo vCard entry, but a local photo, add it to its vCard: Thunderbird
+ // does not store photos of local address books in the internal _vCard property, to reduce
+ // the amount of data stored in its database.
+ let photoName = node.item.getProperty("PhotoName", "");
+ let vCardPhoto = vCardProperties.getFirstValue("photo");
+ if (!vCardPhoto && photoName) {
+ try {
+ let realPhotoFile = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ realPhotoFile.append("Photos");
+ realPhotoFile.append(photoName);
+ let photoFile = await File.createFromNsIFile(realPhotoFile);
+ await addVCardPhotoEntry(vCardProperties, photoFile);
+ } catch (ex) {
+ console.error(
+ `Failed to read photo information for ${node.id}: ` + ex
+ );
+ }
+ }
+
+ // Add the vCard.
+ copy.properties.vCard = vCardProperties.toVCard();
+
+ let parentNode;
+ try {
+ parentNode = this.findAddressBookById(node.parentId);
+ } catch (ex) {
+ // Parent might be a mailing list.
+ parentNode = this.findMailingListById(node.parentId);
+ }
+ copy.readOnly = parentNode.item.readOnly;
+ copy.remote = parentNode.item.isRemote;
+ break;
+ }
+ case "mailingList":
+ copy.name = node.item.dirName;
+ copy.nickName = node.item.listNickName;
+ copy.description = node.item.description;
+ let parentNode = this.findAddressBookById(node.parentId);
+ copy.readOnly = parentNode.item.readOnly;
+ copy.remote = parentNode.item.isRemote;
+ break;
+ }
+
+ return copy;
+ }
+
+ // nsIObserver
+ _notifications = [
+ "addrbook-directory-created",
+ "addrbook-directory-updated",
+ "addrbook-directory-deleted",
+ "addrbook-contact-created",
+ "addrbook-contact-properties-updated",
+ "addrbook-contact-deleted",
+ "addrbook-list-created",
+ "addrbook-list-updated",
+ "addrbook-list-deleted",
+ "addrbook-list-member-added",
+ "addrbook-list-member-removed",
+ ];
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "addrbook-directory-created": {
+ subject.QueryInterface(Ci.nsIAbDirectory);
+
+ let newNode = this._makeDirectoryNode(subject);
+ if (this._addressBooks) {
+ this._addressBooks.set(newNode.id, newNode);
+ }
+
+ this.emit("address-book-created", newNode);
+ break;
+ }
+ case "addrbook-directory-updated": {
+ subject.QueryInterface(Ci.nsIAbDirectory);
+
+ this.emit("address-book-updated", this._makeDirectoryNode(subject));
+ break;
+ }
+ case "addrbook-directory-deleted": {
+ subject.QueryInterface(Ci.nsIAbDirectory);
+
+ let uid = subject.UID;
+ if (this._addressBooks?.has(uid)) {
+ let parentNode = this._addressBooks.get(uid);
+ if (parentNode.contacts) {
+ for (let id of parentNode.contacts.keys()) {
+ this._contacts.delete(id);
+ }
+ }
+ if (parentNode.mailingLists) {
+ for (let id of parentNode.mailingLists.keys()) {
+ this._mailingLists.delete(id);
+ }
+ }
+ this._addressBooks.delete(uid);
+ }
+
+ this.emit("address-book-deleted", uid);
+ break;
+ }
+ case "addrbook-contact-created": {
+ subject.QueryInterface(Ci.nsIAbCard);
+
+ let parent = MailServices.ab.getDirectoryFromUID(data);
+ let newNode = this._makeContactNode(subject, parent);
+ if (this._addressBooks?.has(data)) {
+ let parentNode = this._addressBooks.get(data);
+ if (parentNode.contacts) {
+ parentNode.contacts.set(newNode.id, newNode);
+ }
+ this._contacts.set(newNode.id, newNode);
+ }
+
+ this.emit("contact-created", newNode);
+ break;
+ }
+ case "addrbook-contact-properties-updated": {
+ subject.QueryInterface(Ci.nsIAbCard);
+
+ let parentUID = subject.directoryUID;
+ let parent = MailServices.ab.getDirectoryFromUID(parentUID);
+ let newNode = this._makeContactNode(subject, parent);
+ if (this._addressBooks?.has(parentUID)) {
+ let parentNode = this._addressBooks.get(parentUID);
+ if (parentNode.contacts) {
+ parentNode.contacts.set(newNode.id, newNode);
+ this._contacts.set(newNode.id, newNode);
+ }
+ if (parentNode.mailingLists) {
+ for (let mailingList of parentNode.mailingLists.values()) {
+ if (
+ mailingList.contacts &&
+ mailingList.contacts.has(newNode.id)
+ ) {
+ mailingList.contacts.get(newNode.id).item = subject;
+ }
+ }
+ }
+ }
+
+ this.emit("contact-updated", newNode, JSON.parse(data));
+ break;
+ }
+ case "addrbook-contact-deleted": {
+ subject.QueryInterface(Ci.nsIAbCard);
+
+ let uid = subject.UID;
+ this._contacts.delete(uid);
+ if (this._addressBooks?.has(data)) {
+ let parentNode = this._addressBooks.get(data);
+ if (parentNode.contacts) {
+ parentNode.contacts.delete(uid);
+ }
+ }
+
+ this.emit("contact-deleted", data, uid);
+ break;
+ }
+ case "addrbook-list-created": {
+ subject.QueryInterface(Ci.nsIAbDirectory);
+
+ let parent = MailServices.ab.getDirectoryFromUID(data);
+ let newNode = this._makeDirectoryNode(subject, parent);
+ if (this._addressBooks?.has(data)) {
+ let parentNode = this._addressBooks.get(data);
+ if (parentNode.mailingLists) {
+ parentNode.mailingLists.set(newNode.id, newNode);
+ }
+ this._mailingLists.set(newNode.id, newNode);
+ }
+
+ this.emit("mailing-list-created", newNode);
+ break;
+ }
+ case "addrbook-list-updated": {
+ subject.QueryInterface(Ci.nsIAbDirectory);
+
+ let listNode = this.findMailingListById(subject.UID);
+ listNode.item = subject;
+
+ this.emit("mailing-list-updated", listNode);
+ break;
+ }
+ case "addrbook-list-deleted": {
+ subject.QueryInterface(Ci.nsIAbDirectory);
+
+ let uid = subject.UID;
+ this._mailingLists.delete(uid);
+ if (this._addressBooks?.has(data)) {
+ let parentNode = this._addressBooks.get(data);
+ if (parentNode.mailingLists) {
+ parentNode.mailingLists.delete(uid);
+ }
+ }
+
+ this.emit("mailing-list-deleted", data, uid);
+ break;
+ }
+ case "addrbook-list-member-added": {
+ subject.QueryInterface(Ci.nsIAbCard);
+
+ let parentNode = this.findMailingListById(data);
+ let newNode = this._makeContactNode(subject, parentNode.item);
+ if (
+ this._mailingLists.has(data) &&
+ this._mailingLists.get(data).contacts
+ ) {
+ this._mailingLists.get(data).contacts.set(newNode.id, newNode);
+ }
+ this.emit("mailing-list-member-added", newNode);
+ break;
+ }
+ case "addrbook-list-member-removed": {
+ subject.QueryInterface(Ci.nsIAbCard);
+
+ let uid = subject.UID;
+ if (this._mailingLists.has(data)) {
+ let parentNode = this._mailingLists.get(data);
+ if (parentNode.contacts) {
+ parentNode.contacts.delete(uid);
+ }
+ }
+
+ this.emit("mailing-list-member-removed", data, uid);
+ break;
+ }
+ }
+ }
+
+ incrementListeners() {
+ this.listenerCount++;
+ if (this.listenerCount == 1) {
+ for (let topic of this._notifications) {
+ Services.obs.addObserver(this, topic);
+ }
+ }
+ }
+ decrementListeners() {
+ this.listenerCount--;
+ if (this.listenerCount == 0) {
+ for (let topic of this._notifications) {
+ Services.obs.removeObserver(this, topic);
+ }
+
+ this.flush();
+ }
+ }
+})();
+
+this.addressBook = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ // addressBooks.*
+ onAddressBookCreated({ context, fire }) {
+ let listener = async (event, node) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(await addressBookCache.convert(node));
+ };
+ addressBookCache.on("address-book-created", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("address-book-created", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onAddressBookUpdated({ context, fire }) {
+ let listener = async (event, node) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(await addressBookCache.convert(node));
+ };
+ addressBookCache.on("address-book-updated", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("address-book-updated", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onAddressBookDeleted({ context, fire }) {
+ let listener = async (event, itemUID) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(itemUID);
+ };
+ addressBookCache.on("address-book-deleted", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("address-book-deleted", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+
+ // contacts.*
+ onContactCreated({ context, fire }) {
+ let listener = async (event, node) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(await addressBookCache.convert(node));
+ };
+ addressBookCache.on("contact-created", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("contact-created", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onContactUpdated({ context, fire }) {
+ let listener = async (event, node, changes) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ let filteredChanges = {};
+ // Find changes in flat properties stored in the vCard.
+ if (changes.hasOwnProperty("_vCard")) {
+ let oldVCardProperties = VCardProperties.fromVCard(
+ changes._vCard.oldValue
+ ).toPropertyMap();
+ let newVCardProperties = VCardProperties.fromVCard(
+ changes._vCard.newValue
+ ).toPropertyMap();
+ for (let [name, value] of oldVCardProperties) {
+ if (newVCardProperties.get(name) != value) {
+ filteredChanges[name] = {
+ oldValue: value,
+ newValue: newVCardProperties.get(name) ?? null,
+ };
+ }
+ }
+ for (let [name, value] of newVCardProperties) {
+ if (
+ !filteredChanges.hasOwnProperty(name) &&
+ oldVCardProperties.get(name) != value
+ ) {
+ filteredChanges[name] = {
+ oldValue: oldVCardProperties.get(name) ?? null,
+ newValue: value,
+ };
+ }
+ }
+ }
+ for (let [name, value] of Object.entries(changes)) {
+ if (!filteredChanges.hasOwnProperty(name) && isCustomProperty(name)) {
+ filteredChanges[name] = value;
+ }
+ }
+ fire.sync(await addressBookCache.convert(node), filteredChanges);
+ };
+ addressBookCache.on("contact-updated", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("contact-updated", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onContactDeleted({ context, fire }) {
+ let listener = async (event, parentUID, itemUID) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(parentUID, itemUID);
+ };
+ addressBookCache.on("contact-deleted", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("contact-deleted", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+
+ // mailingLists.*
+ onMailingListCreated({ context, fire }) {
+ let listener = async (event, node) => {
+ fire.sync(await addressBookCache.convert(node));
+ };
+ addressBookCache.on("mailing-list-created", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("mailing-list-created", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onMailingListUpdated({ context, fire }) {
+ let listener = async (event, node) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(await addressBookCache.convert(node));
+ };
+ addressBookCache.on("mailing-list-updated", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("mailing-list-updated", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onMailingListDeleted({ context, fire }) {
+ let listener = async (event, parentUID, itemUID) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(parentUID, itemUID);
+ };
+ addressBookCache.on("mailing-list-deleted", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("mailing-list-deleted", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onMemberAdded({ context, fire }) {
+ let listener = async (event, node) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(await addressBookCache.convert(node));
+ };
+ addressBookCache.on("mailing-list-member-added", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("mailing-list-member-added", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onMemberRemoved({ context, fire }) {
+ let listener = async (event, parentUID, itemUID) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(parentUID, itemUID);
+ };
+ addressBookCache.on("mailing-list-member-removed", listener);
+ return {
+ unregister: () => {
+ addressBookCache.off("mailing-list-member-removed", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ constructor(...args) {
+ super(...args);
+ addressBookCache.incrementListeners();
+ }
+
+ onShutdown() {
+ addressBookCache.decrementListeners();
+ }
+
+ getAPI(context) {
+ let { extension } = context;
+ let { tabManager } = extension;
+
+ return {
+ addressBooks: {
+ async openUI() {
+ let messengerWindow = windowTracker.topNormalWindow;
+ let abWindow = await messengerWindow.toAddressBook();
+ await new Promise(resolve => abWindow.setTimeout(resolve));
+ let abTab = messengerWindow.document
+ .getElementById("tabmail")
+ .tabInfo.find(t => t.mode.name == "addressBookTab");
+ return tabManager.convert(abTab);
+ },
+ async closeUI() {
+ for (let win of Services.wm.getEnumerator("mail:3pane")) {
+ let tabmail = win.document.getElementById("tabmail");
+ for (let tab of tabmail.tabInfo.slice()) {
+ if (tab.browser?.currentURI.spec == "about:addressbook") {
+ tabmail.closeTab(tab);
+ }
+ }
+ }
+ },
+
+ list(complete = false) {
+ return addressBookCache.convert(
+ [...addressBookCache.addressBooks.values()],
+ complete
+ );
+ },
+ get(id, complete = false) {
+ return addressBookCache.convert(
+ addressBookCache.findAddressBookById(id),
+ complete
+ );
+ },
+ create({ name }) {
+ let dirName = MailServices.ab.newAddressBook(
+ name,
+ "",
+ Ci.nsIAbManager.JS_DIRECTORY_TYPE
+ );
+ let directory = MailServices.ab.getDirectoryFromId(dirName);
+ return directory.UID;
+ },
+ update(id, { name }) {
+ let node = addressBookCache.findAddressBookById(id);
+ node.item.dirName = name;
+ },
+ async delete(id) {
+ let node = addressBookCache.findAddressBookById(id);
+ let deletePromise = new Promise(resolve => {
+ let listener = () => {
+ addressBookCache.off("address-book-deleted", listener);
+ resolve();
+ };
+ addressBookCache.on("address-book-deleted", listener);
+ });
+ MailServices.ab.deleteAddressBook(node.item.URI);
+ await deletePromise;
+ },
+
+ // The module name is addressBook as defined in ext-mail.json.
+ onCreated: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onAddressBookCreated",
+ extensionApi: this,
+ }).api(),
+ onUpdated: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onAddressBookUpdated",
+ extensionApi: this,
+ }).api(),
+ onDeleted: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onAddressBookDeleted",
+ extensionApi: this,
+ }).api(),
+
+ provider: {
+ onSearchRequest: new EventManager({
+ context,
+ name: "addressBooks.provider.onSearchRequest",
+ register: (fire, args) => {
+ if (addressBookCache.addressBooks.has(args.id)) {
+ throw new ExtensionUtils.ExtensionError(
+ `addressBook with id=${args.id} already exists.`
+ );
+ }
+ let dir = new ExtSearchBook(fire, context, args);
+ dir.init();
+ MailServices.ab.addAddressBook(dir);
+ return () => {
+ MailServices.ab.deleteAddressBook(dir.URI);
+ };
+ },
+ }).api(),
+ },
+ },
+ contacts: {
+ list(parentId) {
+ let parentNode = addressBookCache.findAddressBookById(parentId);
+ return addressBookCache.convert(
+ addressBookCache.getContacts(parentNode),
+ false
+ );
+ },
+ async quickSearch(parentId, queryInfo) {
+ const { getSearchTokens, getModelQuery, generateQueryURI } =
+ ChromeUtils.import("resource:///modules/ABQueryUtils.jsm");
+
+ let searchString;
+ if (typeof queryInfo == "string") {
+ searchString = queryInfo;
+ queryInfo = {
+ includeRemote: true,
+ includeLocal: true,
+ includeReadOnly: true,
+ includeReadWrite: true,
+ };
+ } else {
+ searchString = queryInfo.searchString;
+ }
+
+ let searchWords = getSearchTokens(searchString);
+ if (searchWords.length == 0) {
+ return [];
+ }
+ let searchFormat = getModelQuery(
+ "mail.addr_book.quicksearchquery.format"
+ );
+ let searchQuery = generateQueryURI(searchFormat, searchWords);
+
+ let booksToSearch;
+ if (parentId == null) {
+ booksToSearch = [...addressBookCache.addressBooks.values()];
+ } else {
+ booksToSearch = [addressBookCache.findAddressBookById(parentId)];
+ }
+
+ let results = [];
+ let promises = [];
+ for (let book of booksToSearch) {
+ if (
+ (book.item.isRemote && !queryInfo.includeRemote) ||
+ (!book.item.isRemote && !queryInfo.includeLocal) ||
+ (book.item.readOnly && !queryInfo.includeReadOnly) ||
+ (!book.item.readOnly && !queryInfo.includeReadWrite)
+ ) {
+ continue;
+ }
+ promises.push(
+ new Promise(resolve => {
+ book.item.search(searchQuery, searchString, {
+ onSearchFinished(status, complete, secInfo, location) {
+ resolve();
+ },
+ onSearchFoundCard(contact) {
+ if (contact.isMailList) {
+ return;
+ }
+ results.push(
+ addressBookCache._makeContactNode(contact, book.item)
+ );
+ },
+ });
+ })
+ );
+ }
+ await Promise.all(promises);
+
+ return addressBookCache.convert(results, false);
+ },
+ get(id) {
+ return addressBookCache.convert(
+ addressBookCache.findContactById(id),
+ false
+ );
+ },
+ async getPhoto(id) {
+ return getPhotoFile(id);
+ },
+ async setPhoto(id, file) {
+ return setPhotoFile(id, file);
+ },
+ create(parentId, id, createData) {
+ let parentNode = addressBookCache.findAddressBookById(parentId);
+ if (parentNode.item.readOnly) {
+ throw new ExtensionUtils.ExtensionError(
+ "Cannot create a contact in a read-only address book"
+ );
+ }
+
+ let card;
+ // A specified vCard is winning over any individual standard property.
+ if (createData.vCard) {
+ try {
+ card = VCardUtils.vCardToAbCard(createData.vCard, id);
+ } catch (ex) {
+ throw new ExtensionError(
+ `Invalid vCard data: ${createData.vCard}.`
+ );
+ }
+ } else {
+ card = flatPropertiesToAbCard(createData, id);
+ }
+ // Add custom properties to the property bag.
+ addProperties(card, createData);
+
+ // Check if the new card has an enforced UID.
+ if (card.vCardProperties.getFirstValue("uid")) {
+ let duplicateExists = false;
+ try {
+ // Second argument is only a hint, all address books are checked.
+ addressBookCache.findContactById(card.UID, parentId);
+ duplicateExists = true;
+ } catch (ex) {
+ // Do nothing. We want this to throw because no contact was found.
+ }
+ if (duplicateExists) {
+ throw new ExtensionError(`Duplicate contact id: ${card.UID}`);
+ }
+ }
+
+ let newCard = parentNode.item.addCard(card);
+ return newCard.UID;
+ },
+ update(id, updateData) {
+ let node = addressBookCache.findContactById(id);
+ let parentNode = addressBookCache.findAddressBookById(node.parentId);
+ if (parentNode.item.readOnly) {
+ throw new ExtensionUtils.ExtensionError(
+ "Cannot modify a contact in a read-only address book"
+ );
+ }
+
+ // A specified vCard is winning over any individual standard property.
+ // While a vCard is replacing the entire contact, specified standard
+ // properties only update single entries (setting a value to null
+ // clears it / promotes the next value of the same kind).
+ let card;
+ if (updateData.vCard) {
+ let vCardUID;
+ try {
+ card = new AddrBookCard();
+ card.UID = node.item.UID;
+ card.setProperty(
+ "_vCard",
+ VCardUtils.translateVCard21(updateData.vCard)
+ );
+ vCardUID = card.vCardProperties.getFirstValue("uid");
+ } catch (ex) {
+ throw new ExtensionError(
+ `Invalid vCard data: ${updateData.vCard}.`
+ );
+ }
+ if (vCardUID && vCardUID != node.item.UID) {
+ throw new ExtensionError(
+ `The card's UID ${node.item.UID} may not be changed: ${updateData.vCard}.`
+ );
+ }
+ } else {
+ // Get the current vCardProperties, build a propertyMap and create
+ // vCardParsed which allows to identify all currently exposed entries
+ // based on the typeName used in VCardUtils.jsm (e.g. adr.work).
+ let vCardProperties = vCardPropertiesFromCard(node.item);
+ let vCardParsed = VCardUtils._parse(vCardProperties.entries);
+ let propertyMap = vCardProperties.toPropertyMap();
+
+ // Save the old exposed state.
+ let oldProperties = VCardProperties.fromPropertyMap(propertyMap);
+ let oldParsed = VCardUtils._parse(oldProperties.entries);
+ // Update the propertyMap.
+ for (let [name, value] of Object.entries(updateData)) {
+ propertyMap.set(name, value);
+ }
+ // Save the new exposed state.
+ let newProperties = VCardProperties.fromPropertyMap(propertyMap);
+ let newParsed = VCardUtils._parse(newProperties.entries);
+
+ // Evaluate the differences and update the still existing entries,
+ // mark removed items for deletion.
+ let deleteLog = [];
+ for (let typeName of oldParsed.keys()) {
+ if (typeName == "version") {
+ continue;
+ }
+ for (let idx = 0; idx < oldParsed.get(typeName).length; idx++) {
+ if (
+ newParsed.has(typeName) &&
+ idx < newParsed.get(typeName).length
+ ) {
+ let originalIndex = vCardParsed.get(typeName)[idx].index;
+ let newEntryIndex = newParsed.get(typeName)[idx].index;
+ vCardProperties.entries[originalIndex] =
+ newProperties.entries[newEntryIndex];
+ // Mark this item as handled.
+ newParsed.get(typeName)[idx] = null;
+ } else {
+ deleteLog.push(vCardParsed.get(typeName)[idx].index);
+ }
+ }
+ }
+
+ // Remove entries which have been marked for deletion.
+ for (let deleteIndex of deleteLog.sort((a, b) => a < b)) {
+ vCardProperties.entries.splice(deleteIndex, 1);
+ }
+
+ // Add new entries.
+ for (let typeName of newParsed.keys()) {
+ if (typeName == "version") {
+ continue;
+ }
+ for (let newEntry of newParsed.get(typeName)) {
+ if (newEntry) {
+ vCardProperties.addEntry(
+ newProperties.entries[newEntry.index]
+ );
+ }
+ }
+ }
+
+ // Create a new card with the original UID from the updated vCardProperties.
+ card = VCardUtils.vCardToAbCard(
+ vCardProperties.toVCard(),
+ node.item.UID
+ );
+ }
+
+ // Clone original properties and update custom properties.
+ addProperties(card, updateData, node.item.properties);
+
+ parentNode.item.modifyCard(card);
+ },
+ delete(id) {
+ let node = addressBookCache.findContactById(id);
+ let parentNode = addressBookCache.findAddressBookById(node.parentId);
+ if (parentNode.item.readOnly) {
+ throw new ExtensionUtils.ExtensionError(
+ "Cannot delete a contact in a read-only address book"
+ );
+ }
+
+ parentNode.item.deleteCards([node.item]);
+ },
+
+ // The module name is addressBook as defined in ext-mail.json.
+ onCreated: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onContactCreated",
+ extensionApi: this,
+ }).api(),
+ onUpdated: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onContactUpdated",
+ extensionApi: this,
+ }).api(),
+ onDeleted: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onContactDeleted",
+ extensionApi: this,
+ }).api(),
+ },
+ mailingLists: {
+ list(parentId) {
+ let parentNode = addressBookCache.findAddressBookById(parentId);
+ return addressBookCache.convert(
+ addressBookCache.getMailingLists(parentNode),
+ false
+ );
+ },
+ get(id) {
+ return addressBookCache.convert(
+ addressBookCache.findMailingListById(id),
+ false
+ );
+ },
+ create(parentId, { name, nickName, description }) {
+ let parentNode = addressBookCache.findAddressBookById(parentId);
+ if (parentNode.item.readOnly) {
+ throw new ExtensionUtils.ExtensionError(
+ "Cannot create a mailing list in a read-only address book"
+ );
+ }
+ let mailList = Cc[
+ "@mozilla.org/addressbook/directoryproperty;1"
+ ].createInstance(Ci.nsIAbDirectory);
+ mailList.isMailList = true;
+ mailList.dirName = name;
+ mailList.listNickName = nickName === null ? "" : nickName;
+ mailList.description = description === null ? "" : description;
+
+ let newMailList = parentNode.item.addMailList(mailList);
+ return newMailList.UID;
+ },
+ update(id, { name, nickName, description }) {
+ let node = addressBookCache.findMailingListById(id);
+ let parentNode = addressBookCache.findAddressBookById(node.parentId);
+ if (parentNode.item.readOnly) {
+ throw new ExtensionUtils.ExtensionError(
+ "Cannot modify a mailing list in a read-only address book"
+ );
+ }
+ node.item.dirName = name;
+ node.item.listNickName = nickName === null ? "" : nickName;
+ node.item.description = description === null ? "" : description;
+ node.item.editMailListToDatabase(null);
+ },
+ delete(id) {
+ let node = addressBookCache.findMailingListById(id);
+ let parentNode = addressBookCache.findAddressBookById(node.parentId);
+ if (parentNode.item.readOnly) {
+ throw new ExtensionUtils.ExtensionError(
+ "Cannot delete a mailing list in a read-only address book"
+ );
+ }
+ parentNode.item.deleteDirectory(node.item);
+ },
+
+ listMembers(id) {
+ let node = addressBookCache.findMailingListById(id);
+ return addressBookCache.convert(
+ addressBookCache.getListContacts(node),
+ false
+ );
+ },
+ addMember(id, contactId) {
+ let node = addressBookCache.findMailingListById(id);
+ let parentNode = addressBookCache.findAddressBookById(node.parentId);
+ if (parentNode.item.readOnly) {
+ throw new ExtensionUtils.ExtensionError(
+ "Cannot add to a mailing list in a read-only address book"
+ );
+ }
+ let contactNode = addressBookCache.findContactById(contactId);
+ node.item.addCard(contactNode.item);
+ },
+ removeMember(id, contactId) {
+ let node = addressBookCache.findMailingListById(id);
+ let parentNode = addressBookCache.findAddressBookById(node.parentId);
+ if (parentNode.item.readOnly) {
+ throw new ExtensionUtils.ExtensionError(
+ "Cannot remove from a mailing list in a read-only address book"
+ );
+ }
+ let contactNode = addressBookCache.findContactById(contactId);
+
+ node.item.deleteCards([contactNode.item]);
+ },
+
+ // The module name is addressBook as defined in ext-mail.json.
+ onCreated: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onMailingListCreated",
+ extensionApi: this,
+ }).api(),
+ onUpdated: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onMailingListUpdated",
+ extensionApi: this,
+ }).api(),
+ onDeleted: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onMailingListDeleted",
+ extensionApi: this,
+ }).api(),
+ onMemberAdded: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onMemberAdded",
+ extensionApi: this,
+ }).api(),
+ onMemberRemoved: new EventManager({
+ context,
+ module: "addressBook",
+ event: "onMemberRemoved",
+ extensionApi: this,
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-browserAction.js b/comm/mail/components/extensions/parent/ext-browserAction.js
new file mode 100644
index 0000000000..de07f9e3a2
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-browserAction.js
@@ -0,0 +1,329 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ storeState: "resource:///modules/CustomizationState.mjs",
+ getState: "resource:///modules/CustomizationState.mjs",
+ registerExtension: "resource:///modules/CustomizableItems.sys.mjs",
+ unregisterExtension: "resource:///modules/CustomizableItems.sys.mjs",
+ EXTENSION_PREFIX: "resource:///modules/CustomizableItems.sys.mjs",
+});
+var { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+XPCOMUtils.defineLazyModuleGetters(this, {
+ ToolbarButtonAPI: "resource:///modules/ExtensionToolbarButtons.jsm",
+ getCachedAllowedSpaces: "resource:///modules/ExtensionToolbarButtons.jsm",
+ setCachedAllowedSpaces: "resource:///modules/ExtensionToolbarButtons.jsm",
+});
+
+var { makeWidgetId } = ExtensionCommon;
+
+const browserActionMap = new WeakMap();
+
+this.browserAction = class extends ToolbarButtonAPI {
+ static for(extension) {
+ return browserActionMap.get(extension);
+ }
+
+ /**
+ * A browser_action can be placed in the unified toolbar of the main window and
+ * in the XUL toolbar of the message window. We conditionally bypass XUL toolbar
+ * behavior by using the following custom method implementations.
+ */
+
+ paint(window) {
+ // Ignore XUL toolbar paint requests for the main window.
+ if (window.location.href != MAIN_WINDOW_URI) {
+ super.paint(window);
+ }
+ }
+
+ unpaint(window) {
+ // Ignore XUL toolbar unpaint requests for the main window.
+ if (window.location.href != MAIN_WINDOW_URI) {
+ super.unpaint(window);
+ }
+ }
+
+ /**
+ * Return the toolbar button if it is currently visible in the given window.
+ *
+ * @param window
+ * @returns {DOMElement} the toolbar button element, or null
+ */
+ getToolbarButton(window) {
+ // Return the visible button from the unified toolbar, if this is the main window.
+ if (window.location.href == MAIN_WINDOW_URI) {
+ let buttonItem = window.document.querySelector(
+ `#unifiedToolbarContent [item-id="ext-${this.extension.id}"]`
+ );
+ return (
+ buttonItem &&
+ !buttonItem.hidden &&
+ window.document.querySelector(
+ `#unifiedToolbarContent [extension="${this.extension.id}"]`
+ )
+ );
+ }
+ return super.getToolbarButton(window);
+ }
+
+ updateButton(button, tabData) {
+ if (button.applyTabData) {
+ // This is an extension-action-button customElement and therefore a button
+ // in the unified toolbar and needs special handling.
+ button.applyTabData(tabData);
+ } else {
+ super.updateButton(button, tabData);
+ }
+ }
+
+ async onManifestEntry(entryName) {
+ await super.onManifestEntry(entryName);
+ browserActionMap.set(this.extension, this);
+
+ // Check if a browser_action was added to the unified toolbar.
+ if (this.windowURLs.includes(MAIN_WINDOW_URI)) {
+ await registerExtension(this.extension.id, this.allowedSpaces);
+ const currentToolbarState = getState();
+ const unifiedToolbarButtonId = `${EXTENSION_PREFIX}${this.extension.id}`;
+
+ // Load the cached allowed spaces. Make sure there are no awaited promises
+ // before storing the updated allowed spaces, as it could have been changed
+ // elsewhere.
+ let cachedAllowedSpaces = getCachedAllowedSpaces();
+ let priorAllowedSpaces = cachedAllowedSpaces.get(this.extension.id);
+
+ // If the extension has set allowedSpaces to an empty array, the button needs
+ // to be added to all available spaces.
+ let allowedSpaces =
+ this.allowedSpaces.length == 0
+ ? [
+ "mail",
+ "addressbook",
+ "calendar",
+ "tasks",
+ "chat",
+ "settings",
+ "default",
+ ]
+ : this.allowedSpaces;
+
+ // Manually add the button to all customized spaces, where it has not been
+ // allowed in the prior version of this add-on (if any). This automatically
+ // covers the install and the update case, including staged updates.
+ // Spaces which have not been customized will receive the button from
+ // getDefaultItemIdsForSpace() in CustomizableItems.sys.mjs.
+ let missingSpacesInState = allowedSpaces.filter(
+ space =>
+ (!priorAllowedSpaces || !priorAllowedSpaces.includes(space)) &&
+ space !== "default" &&
+ currentToolbarState.hasOwnProperty(space) &&
+ !currentToolbarState[space].includes(unifiedToolbarButtonId)
+ );
+ for (const space of missingSpacesInState) {
+ currentToolbarState[space].push(unifiedToolbarButtonId);
+ }
+
+ // Manually remove button from all customized spaces, if it is no longer
+ // allowed. This will remove its stored customized positioning information.
+ // If a space becomes allowed again later, the button will be added to the
+ // end of the space and not at its former customized location.
+ let invalidSpacesInState = [];
+ if (priorAllowedSpaces) {
+ invalidSpacesInState = priorAllowedSpaces.filter(
+ space =>
+ space !== "default" &&
+ !allowedSpaces.includes(space) &&
+ currentToolbarState.hasOwnProperty(space) &&
+ currentToolbarState[space].includes(unifiedToolbarButtonId)
+ );
+ for (const space of invalidSpacesInState) {
+ currentToolbarState[space] = currentToolbarState[space].filter(
+ id => id != unifiedToolbarButtonId
+ );
+ }
+ }
+
+ // Update the cached values for the allowed spaces.
+ cachedAllowedSpaces.set(this.extension.id, allowedSpaces);
+ setCachedAllowedSpaces(cachedAllowedSpaces);
+
+ if (missingSpacesInState.length || invalidSpacesInState.length) {
+ storeState(currentToolbarState);
+ } else {
+ Services.obs.notifyObservers(null, "unified-toolbar-state-change");
+ }
+ }
+ }
+
+ close() {
+ super.close();
+ browserActionMap.delete(this.extension);
+ windowTracker.removeListener("TabSelect", this);
+ // Unregister the extension from the unified toolbar.
+ if (this.windowURLs.includes(MAIN_WINDOW_URI)) {
+ unregisterExtension(this.extension.id);
+ Services.obs.notifyObservers(null, "unified-toolbar-state-change");
+ }
+ }
+
+ constructor(extension) {
+ super(extension, global);
+ this.manifest_name =
+ extension.manifestVersion < 3 ? "browser_action" : "action";
+ this.manifestName =
+ extension.manifestVersion < 3 ? "browserAction" : "action";
+ this.manifest = extension.manifest[this.manifest_name];
+ // browserAction was renamed to action in MV3, but its module name is
+ // still "browserAction" because that is the name used in ext-mail.json,
+ // independently from the manifest version.
+ this.moduleName = "browserAction";
+
+ this.windowURLs = [];
+ if (this.manifest.default_windows.includes("normal")) {
+ this.windowURLs.push(MAIN_WINDOW_URI);
+ }
+ if (this.manifest.default_windows.includes("messageDisplay")) {
+ this.windowURLs.push(MESSAGE_WINDOW_URI);
+ }
+
+ this.toolboxId = "mail-toolbox";
+ this.toolbarId = "mail-bar3";
+
+ this.allowedSpaces =
+ this.extension.manifest[this.manifest_name].allowed_spaces;
+
+ windowTracker.addListener("TabSelect", this);
+ }
+
+ static onUpdate(extensionId, manifest) {
+ // These manifest entries can exist and be null.
+ if (!manifest.browser_action && !manifest.action) {
+ this.#removeFromUnifiedToolbar(extensionId);
+ }
+ }
+
+ static onUninstall(extensionId) {
+ let widgetId = makeWidgetId(extensionId);
+ let id = `${widgetId}-browserAction-toolbarbutton`;
+
+ // Check all possible XUL toolbars and remove the toolbarbutton if found.
+ // Sadly we have to hardcode these values here, as the add-on is already
+ // shutdown when onUninstall is called.
+ let toolbars = ["mail-bar3", "toolbar-menubar"];
+ for (let toolbar of toolbars) {
+ for (let setName of ["currentset", "extensionset"]) {
+ let set = Services.xulStore
+ .getValue(MESSAGE_WINDOW_URI, toolbar, setName)
+ .split(",");
+ let newSet = set.filter(e => e != id);
+ if (newSet.length < set.length) {
+ Services.xulStore.setValue(
+ MESSAGE_WINDOW_URI,
+ toolbar,
+ setName,
+ newSet.join(",")
+ );
+ }
+ }
+ }
+
+ this.#removeFromUnifiedToolbar(extensionId);
+ }
+
+ static #removeFromUnifiedToolbar(extensionId) {
+ const currentToolbarState = getState();
+ const unifiedToolbarButtonId = `${EXTENSION_PREFIX}${extensionId}`;
+ let modifiedState = false;
+ for (const space of Object.keys(currentToolbarState)) {
+ if (currentToolbarState[space].includes(unifiedToolbarButtonId)) {
+ currentToolbarState[space].splice(
+ currentToolbarState[space].indexOf(unifiedToolbarButtonId),
+ 1
+ );
+ modifiedState = true;
+ }
+ }
+ if (modifiedState) {
+ storeState(currentToolbarState);
+ }
+
+ // Update cachedAllowedSpaces for the unified toolbar.
+ let cachedAllowedSpaces = getCachedAllowedSpaces();
+ if (cachedAllowedSpaces.has(extensionId)) {
+ cachedAllowedSpaces.delete(extensionId);
+ setCachedAllowedSpaces(cachedAllowedSpaces);
+ }
+ }
+
+ handleEvent(event) {
+ super.handleEvent(event);
+ let window = event.target.ownerGlobal;
+
+ switch (event.type) {
+ case "popupshowing":
+ const menu = event.target;
+ if (menu.tagName != "menupopup") {
+ return;
+ }
+
+ // This needs to work in normal window and message window.
+ let tab = tabTracker.activeTab;
+ let browser = tab.linkedBrowser || tab.getBrowser?.();
+
+ const trigger = menu.triggerNode;
+ const node =
+ window.document.getElementById(this.id) ||
+ (this.windowURLs.includes(MAIN_WINDOW_URI) &&
+ window.document.querySelector(
+ `#unifiedToolbarContent [item-id="${EXTENSION_PREFIX}${this.extension.id}"]`
+ ));
+ const contexts = [
+ "toolbar-context-menu",
+ "customizationPanelItemContextMenu",
+ "unifiedToolbarMenu",
+ ];
+ if (contexts.includes(menu.id) && node && node.contains(trigger)) {
+ const action =
+ this.extension.manifestVersion < 3 ? "onBrowserAction" : "onAction";
+ global.actionContextMenu({
+ tab,
+ pageUrl: browser?.currentURI?.spec,
+ extension: this.extension,
+ [action]: true,
+ menu,
+ });
+ }
+
+ if (
+ menu.dataset.actionMenu == this.manifestName &&
+ this.extension.id == menu.dataset.extensionId
+ ) {
+ const action =
+ this.extension.manifestVersion < 3
+ ? "inBrowserActionMenu"
+ : "inActionMenu";
+ global.actionContextMenu({
+ tab,
+ pageUrl: browser?.currentURI?.spec,
+ extension: this.extension,
+ [action]: true,
+ menu,
+ });
+ }
+ break;
+ }
+ }
+};
+
+global.browserActionFor = this.browserAction.for;
diff --git a/comm/mail/components/extensions/parent/ext-chrome-settings-overrides.js b/comm/mail/components/extensions/parent/ext-chrome-settings-overrides.js
new file mode 100644
index 0000000000..9f3d624b76
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-chrome-settings-overrides.js
@@ -0,0 +1,365 @@
+/* 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/. */
+
+/* global searchInitialized */
+
+// Copy of browser/components/extensions/parent/ext-chrome-settings-overrides.js
+// minus HomePage.jsm (+ dependent ExtensionControlledPopup.sys.mjs and
+// ExtensionPermissions.jsm usage).
+
+"use strict";
+
+var { ExtensionPreferencesManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPreferencesManager.sys.mjs"
+);
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionSettingsStore:
+ "resource://gre/modules/ExtensionSettingsStore.sys.mjs",
+});
+const DEFAULT_SEARCH_STORE_TYPE = "default_search";
+const DEFAULT_SEARCH_SETTING_NAME = "defaultSearch";
+const ENGINE_ADDED_SETTING_NAME = "engineAdded";
+
+// When an extension starts up, a search engine may asynchronously be
+// registered, without blocking the startup. When an extension is
+// uninstalled, we need to wait for this registration to finish
+// before running the uninstallation handler.
+// Map[extension id -> Promise]
+var pendingSearchSetupTasks = new Map();
+
+this.chrome_settings_overrides = class extends ExtensionAPI {
+ static async processDefaultSearchSetting(action, id) {
+ await ExtensionSettingsStore.initialize();
+ let item = ExtensionSettingsStore.getSetting(
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME,
+ id
+ );
+ if (!item) {
+ return;
+ }
+ let control = await ExtensionSettingsStore.getLevelOfControl(
+ id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+ item = ExtensionSettingsStore[action](
+ id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+ if (item && control == "controlled_by_this_extension") {
+ try {
+ let engine = Services.search.getEngineByName(
+ item.value || item.initialValue
+ );
+ if (engine) {
+ await Services.search.setDefault(
+ engine,
+ action == "enable"
+ ? Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL
+ : Ci.nsISearchService.CHANGE_REASON_ADDON_UNINSTALL
+ );
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ }
+
+ static async removeEngine(id) {
+ try {
+ await Services.search.removeWebExtensionEngine(id);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ static removeSearchSettings(id) {
+ return Promise.all([
+ this.processDefaultSearchSetting("removeSetting", id),
+ this.removeEngine(id),
+ ]);
+ }
+
+ static async onUninstall(id) {
+ let searchStartupPromise = pendingSearchSetupTasks.get(id);
+ if (searchStartupPromise) {
+ await searchStartupPromise.catch(console.error);
+ }
+ // Note: We do not have to deal with homepage here as it is managed by
+ // the ExtensionPreferencesManager.
+ return Promise.all([this.removeSearchSettings(id)]);
+ }
+
+ static async onUpdate(id, manifest) {
+ let search_provider = manifest?.chrome_settings_overrides?.search_provider;
+
+ if (!search_provider) {
+ // Remove setting and engine from search if necessary.
+ this.removeSearchSettings(id);
+ } else if (!search_provider.is_default) {
+ // Remove the setting, but keep the engine in search.
+ chrome_settings_overrides.processDefaultSearchSetting(
+ "removeSetting",
+ id
+ );
+ }
+ }
+
+ static async onDisable(id) {
+ await chrome_settings_overrides.processDefaultSearchSetting("disable", id);
+ await chrome_settings_overrides.removeEngine(id);
+ }
+
+ async onManifestEntry(entryName) {
+ let { extension } = this;
+ let { manifest } = extension;
+ if (manifest.chrome_settings_overrides.search_provider) {
+ // Registering a search engine can potentially take a long while,
+ // or not complete at all (when searchInitialized is never resolved),
+ // so we are deliberately not awaiting the returned promise here.
+ let searchStartupPromise =
+ this.processSearchProviderManifestEntry().finally(() => {
+ if (
+ pendingSearchSetupTasks.get(extension.id) === searchStartupPromise
+ ) {
+ pendingSearchSetupTasks.delete(extension.id);
+ // This is primarily for tests so that we know when an extension
+ // has finished initialising.
+ ExtensionParent.apiManager.emit("searchEngineProcessed", extension);
+ }
+ });
+
+ // Save the promise so we can await at onUninstall.
+ pendingSearchSetupTasks.set(extension.id, searchStartupPromise);
+ }
+ }
+
+ async ensureSetting(engineName, disable = false) {
+ let { extension } = this;
+ // Ensure the addon always has a setting
+ await ExtensionSettingsStore.initialize();
+ let item = ExtensionSettingsStore.getSetting(
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME,
+ extension.id
+ );
+ if (!item) {
+ let defaultEngine = await Services.search.getDefault();
+ item = await ExtensionSettingsStore.addSetting(
+ extension.id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME,
+ engineName,
+ () => defaultEngine.name
+ );
+ // If there was no setting, we're fixing old behavior in this api.
+ // A lack of a setting would mean it was disabled before, disable it now.
+ disable =
+ disable ||
+ ["ADDON_UPGRADE", "ADDON_DOWNGRADE", "ADDON_ENABLE"].includes(
+ extension.startupReason
+ );
+ }
+
+ // Ensure the item is disabled (either if exists and is not default or if it does not
+ // exist yet).
+ if (disable) {
+ item = await ExtensionSettingsStore.disable(
+ extension.id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+ }
+ return item;
+ }
+
+ async promptDefaultSearch(engineName) {
+ let { extension } = this;
+ // Don't ask if it is already the current engine
+ let engine = Services.search.getEngineByName(engineName);
+ let defaultEngine = await Services.search.getDefault();
+ if (defaultEngine.name == engine.name) {
+ return;
+ }
+ // Ensures the setting exists and is disabled. If the
+ // user somehow bypasses the prompt, we do not want this
+ // setting enabled for this extension.
+ await this.ensureSetting(engineName, true);
+
+ let subject = {
+ wrappedJSObject: {
+ // This is a hack because we don't have the browser of
+ // the actual install. This means the popup might show
+ // in a different window. Will be addressed in a followup bug.
+ // As well, we still notify if no topWindow exists to support
+ // testing from xpcshell.
+ browser: windowTracker.topWindow?.gBrowser.selectedBrowser,
+ id: extension.id,
+ name: extension.name,
+ icon: extension.iconURL,
+ currentEngine: defaultEngine.name,
+ newEngine: engineName,
+ async respond(allow) {
+ if (allow) {
+ await chrome_settings_overrides.processDefaultSearchSetting(
+ "enable",
+ extension.id
+ );
+ await Services.search.setDefault(
+ Services.search.getEngineByName(engineName),
+ Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL
+ );
+ }
+ // For testing
+ Services.obs.notifyObservers(
+ null,
+ "webextension-defaultsearch-prompt-response"
+ );
+ },
+ },
+ };
+ Services.obs.notifyObservers(subject, "webextension-defaultsearch-prompt");
+ }
+
+ async processSearchProviderManifestEntry() {
+ let { extension } = this;
+ let { manifest } = extension;
+ let searchProvider = manifest.chrome_settings_overrides.search_provider;
+
+ // If we're not being requested to be set as default, then all we need
+ // to do is to add the engine to the service. The search service can cope
+ // with receiving added engines before it is initialised, so we don't have
+ // to wait for it. Search Service will also prevent overriding a builtin
+ // engine appropriately.
+ if (!searchProvider.is_default) {
+ await this.addSearchEngine();
+ return;
+ }
+
+ await searchInitialized;
+ if (!this.extension) {
+ console.error(
+ `Extension shut down before search provider was registered`
+ );
+ return;
+ }
+
+ let engineName = searchProvider.name.trim();
+ let result = await Services.search.maybeSetAndOverrideDefault(extension);
+ // This will only be set to true when the specified engine is an app-provided
+ // engine, or when it is an allowed add-on defined in the list stored in
+ // SearchDefaultOverrideAllowlistHandler.
+ if (result.canChangeToAppProvided) {
+ await this.setDefault(engineName, true);
+ }
+ if (!result.canInstallEngine) {
+ // This extension is overriding an app-provided one, so we don't
+ // add its engine as well.
+ return;
+ }
+ await this.addSearchEngine();
+ if (extension.startupReason === "ADDON_INSTALL") {
+ await this.promptDefaultSearch(engineName);
+ } else {
+ // Needs to be called every time to handle reenabling.
+ await this.setDefault(engineName);
+ }
+ }
+
+ async setDefault(engineName, skipEnablePrompt = false) {
+ let { extension } = this;
+ if (extension.startupReason === "ADDON_INSTALL") {
+ // We should only get here if an extension is setting an app-provided
+ // engine to default and we are ignoring the addons other engine settings.
+ // In this case we do not show the prompt to the user.
+ let item = await this.ensureSetting(engineName);
+ await Services.search.setDefault(
+ Services.search.getEngineByName(item.value),
+ Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL
+ );
+ } else if (
+ ["ADDON_UPGRADE", "ADDON_DOWNGRADE", "ADDON_ENABLE"].includes(
+ extension.startupReason
+ )
+ ) {
+ // We would be called for every extension being enabled, we should verify
+ // that it has control and only then set it as default
+ let control = await ExtensionSettingsStore.getLevelOfControl(
+ extension.id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+
+ // Check for an inconsistency between the value returned by getLevelOfcontrol
+ // and the current engine actually set.
+ if (
+ control === "controlled_by_this_extension" &&
+ Services.search.defaultEngine.name !== engineName
+ ) {
+ // Check for and fix any inconsistency between the extensions settings storage
+ // and the current engine actually set. If settings claims the extension is default
+ // but the search service claims otherwise, select what the search service claims
+ // (See Bug 1767550).
+ const allSettings = ExtensionSettingsStore.getAllSettings(
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+ for (const setting of allSettings) {
+ if (setting.value !== Services.search.defaultEngine.name) {
+ await ExtensionSettingsStore.disable(
+ setting.id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+ }
+ }
+ control = await ExtensionSettingsStore.getLevelOfControl(
+ extension.id,
+ DEFAULT_SEARCH_STORE_TYPE,
+ DEFAULT_SEARCH_SETTING_NAME
+ );
+ }
+
+ if (control === "controlled_by_this_extension") {
+ await Services.search.setDefault(
+ Services.search.getEngineByName(engineName),
+ Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL
+ );
+ } else if (control === "controllable_by_this_extension") {
+ if (skipEnablePrompt) {
+ // For overriding app-provided engines, we don't prompt, so set
+ // the default straight away.
+ await chrome_settings_overrides.processDefaultSearchSetting(
+ "enable",
+ extension.id
+ );
+ await Services.search.setDefault(
+ Services.search.getEngineByName(engineName),
+ Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL
+ );
+ } else if (extension.startupReason == "ADDON_ENABLE") {
+ // This extension has precedence, but is not in control. Ask the user.
+ await this.promptDefaultSearch(engineName);
+ }
+ }
+ }
+ }
+
+ async addSearchEngine() {
+ let { extension } = this;
+ try {
+ await Services.search.addEnginesFromExtension(extension);
+ } catch (e) {
+ console.error(e);
+ return false;
+ }
+ return true;
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-cloudFile.js b/comm/mail/components/extensions/parent/ext-cloudFile.js
new file mode 100644
index 0000000000..74193d8d14
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-cloudFile.js
@@ -0,0 +1,804 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+var { cloudFileAccounts } = ChromeUtils.import(
+ "resource:///modules/cloudFileAccounts.jsm"
+);
+
+// eslint-disable-next-line mozilla/reject-importGlobalProperties
+Cu.importGlobalProperties(["File", "FileReader"]);
+
+async function promiseFileRead(nsifile) {
+ let blob = await File.createFromNsIFile(nsifile);
+
+ return new Promise((resolve, reject) => {
+ let reader = new FileReader();
+ reader.addEventListener("loadend", event => {
+ if (event.target.error) {
+ reject(event.target.error);
+ } else {
+ resolve(event.target.result);
+ }
+ });
+
+ reader.readAsArrayBuffer(blob);
+ });
+}
+
+class CloudFileAccount {
+ constructor(accountKey, extension) {
+ this.accountKey = accountKey;
+ this.extension = extension;
+ this._configured = false;
+ this.lastError = "";
+ this.managementURL = this.extension.manifest.cloud_file.management_url;
+ this.reuseUploads = this.extension.manifest.cloud_file.reuse_uploads;
+ this.browserStyle = this.extension.manifest.cloud_file.browser_style;
+ this.quota = {
+ uploadSizeLimit: -1,
+ spaceRemaining: -1,
+ spaceUsed: -1,
+ };
+
+ this._nextId = 1;
+ this._uploads = new Map();
+ }
+
+ get type() {
+ return `ext-${this.extension.id}`;
+ }
+ get displayName() {
+ return Services.prefs.getCharPref(
+ `mail.cloud_files.accounts.${this.accountKey}.displayName`,
+ this.extension.manifest.cloud_file.name
+ );
+ }
+ get iconURL() {
+ if (this.extension.manifest.icons) {
+ let { icon } = ExtensionParent.IconDetails.getPreferredIcon(
+ this.extension.manifest.icons,
+ this.extension,
+ 32
+ );
+ return this.extension.baseURI.resolve(icon);
+ }
+ return "chrome://messenger/content/extension.svg";
+ }
+ get fileUploadSizeLimit() {
+ return this.quota.uploadSizeLimit;
+ }
+ get remainingFileSpace() {
+ return this.quota.spaceRemaining;
+ }
+ get fileSpaceUsed() {
+ return this.quota.spaceUsed;
+ }
+ get configured() {
+ return this._configured;
+ }
+ set configured(value) {
+ value = !!value;
+ if (value != this._configured) {
+ this._configured = value;
+ cloudFileAccounts.emit("accountConfigured", this);
+ }
+ }
+ get createNewAccountUrl() {
+ return this.extension.manifest.cloud_file.new_account_url;
+ }
+
+ /**
+ * @typedef CloudFileDate
+ * @property {integer} timestamp - milliseconds since epoch
+ * @property {DateTimeFormat} format - format object of Intl.DateTimeFormat
+ */
+
+ /**
+ * @typedef CloudFileUpload
+ * // Values used in the WebExtension CloudFile type.
+ * @property {string} id - uploadId of the file
+ * @property {string} name - name of the file
+ * @property {string} url - url of the uploaded file
+ * // Properties of the local file.
+ * @property {string} path - path of the local file
+ * @property {string} size - size of the local file
+ * // Template information.
+ * @property {string} serviceName - name of the upload service provider
+ * @property {string} serviceIcon - icon of the upload service provider
+ * @property {string} serviceUrl - web interface of the upload service provider
+ * @property {boolean} downloadPasswordProtected - link is password protected
+ * @property {integer} downloadLimit - download limit of the link
+ * @property {CloudFileDate} downloadExpiryDate - expiry date of the link
+ * // Usage tracking.
+ * @property {boolean} immutable - if the cloud file url may be changed
+ */
+
+ /**
+ * Marks the specified upload as immutable.
+ *
+ * @param {integer} id - id of the upload
+ */
+ markAsImmutable(id) {
+ if (this._uploads.has(id)) {
+ let upload = this._uploads.get(id);
+ upload.immutable = true;
+ this._uploads.set(id, upload);
+ }
+ }
+
+ /**
+ * Returns a new upload entry, based on the provided file and data.
+ *
+ * @param {nsIFile} file
+ * @param {CloudFileUpload} data
+ * @returns {CloudFileUpload}
+ */
+ newUploadForFile(file, data = {}) {
+ let id = this._nextId++;
+ let upload = {
+ // Values used in the WebExtension CloudFile type.
+ id,
+ name: data.name ?? file.leafName,
+ url: data.url ?? null,
+ // Properties of the local file.
+ path: file.path,
+ size: file.exists() ? file.fileSize : data.size || 0,
+ // Template information.
+ serviceName: data.serviceName ?? this.displayName,
+ serviceIcon: data.serviceIcon ?? this.iconURL,
+ serviceUrl: data.serviceUrl ?? "",
+ downloadPasswordProtected: data.downloadPasswordProtected ?? false,
+ downloadLimit: data.downloadLimit ?? 0,
+ downloadExpiryDate: data.downloadExpiryDate ?? null,
+ // Usage tracking.
+ immutable: data.immutable ?? false,
+ };
+
+ this._uploads.set(id, upload);
+ return upload;
+ }
+
+ /**
+ * Initiate a WebExtension cloudFile upload by preparing a CloudFile object &
+ * and triggering an onFileUpload event.
+ *
+ * @param {object} window Window object of the window, where the upload has
+ * been initiated. Must be null, if the window is not supported by the
+ * WebExtension windows/tabs API. Currently, this should only be set by the
+ * compose window.
+ * @param {nsIFile} file File to be uploaded.
+ * @param {string} [name] Name of the file after it has been uploaded. Defaults
+ * to the original filename of the uploaded file.
+ * @param {CloudFileUpload} relatedCloudFileUpload Information about an already
+ * uploaded file this upload is related to, e.g. renaming a repeatedly used
+ * cloud file or updating the content of a cloud file.
+ * @returns {CloudFileUpload} Information about the uploaded file.
+ */
+ async uploadFile(window, file, name = file.leafName, relatedCloudFileUpload) {
+ let data = await File.createFromNsIFile(file);
+
+ if (
+ this.remainingFileSpace != -1 &&
+ file.fileSize > this.remainingFileSpace
+ ) {
+ throw Components.Exception(
+ `Quota error: Can't upload file. Only ${this.remainingFileSpace}KB left of quota.`,
+ cloudFileAccounts.constants.uploadWouldExceedQuota
+ );
+ }
+
+ if (
+ this.fileUploadSizeLimit != -1 &&
+ file.fileSize > this.fileUploadSizeLimit
+ ) {
+ throw Components.Exception(
+ `Upload error: File size is ${file.fileSize}KB and exceeds the file size limit of ${this.fileUploadSizeLimit}KB`,
+ cloudFileAccounts.constants.uploadExceedsFileLimit
+ );
+ }
+
+ let upload = this.newUploadForFile(file, { name });
+ let id = upload.id;
+ let relatedFileInfo;
+ if (relatedCloudFileUpload) {
+ relatedFileInfo = {
+ id: relatedCloudFileUpload.id,
+ name: relatedCloudFileUpload.name,
+ url: relatedCloudFileUpload.url,
+ templateInfo: relatedCloudFileUpload.templateInfo,
+ dataChanged: relatedCloudFileUpload.path != upload.path,
+ };
+ }
+
+ let results;
+ try {
+ results = await this.extension.emit(
+ "uploadFile",
+ this,
+ { id, name, data },
+ window,
+ relatedFileInfo
+ );
+ } catch (ex) {
+ this._uploads.delete(id);
+ if (ex.result == 0x80530014) {
+ // NS_ERROR_DOM_ABORT_ERR
+ throw Components.Exception(
+ "Upload cancelled.",
+ cloudFileAccounts.constants.uploadCancelled
+ );
+ } else {
+ throw Components.Exception(
+ `Upload error: ${ex.message}`,
+ cloudFileAccounts.constants.uploadErr
+ );
+ }
+ }
+
+ if (
+ results &&
+ results.length > 0 &&
+ results[0] &&
+ (results[0].aborted || results[0].url || results[0].error)
+ ) {
+ if (results[0].error) {
+ this._uploads.delete(id);
+ if (typeof results[0].error == "boolean") {
+ throw Components.Exception(
+ "Upload error.",
+ cloudFileAccounts.constants.uploadErr
+ );
+ } else {
+ throw Components.Exception(
+ results[0].error,
+ cloudFileAccounts.constants.uploadErrWithCustomMessage
+ );
+ }
+ }
+
+ if (results[0].aborted) {
+ this._uploads.delete(id);
+ throw Components.Exception(
+ "Upload cancelled.",
+ cloudFileAccounts.constants.uploadCancelled
+ );
+ }
+
+ if (results[0].templateInfo) {
+ upload.templateInfo = results[0].templateInfo;
+
+ if (results[0].templateInfo.service_name) {
+ upload.serviceName = results[0].templateInfo.service_name;
+ }
+ if (results[0].templateInfo.service_icon) {
+ upload.serviceIcon = this.extension.baseURI.resolve(
+ results[0].templateInfo.service_icon
+ );
+ }
+ if (results[0].templateInfo.service_url) {
+ upload.serviceUrl = results[0].templateInfo.service_url;
+ }
+ if (results[0].templateInfo.download_password_protected) {
+ upload.downloadPasswordProtected =
+ results[0].templateInfo.download_password_protected;
+ }
+ if (results[0].templateInfo.download_limit) {
+ upload.downloadLimit = results[0].templateInfo.download_limit;
+ }
+ if (results[0].templateInfo.download_expiry_date) {
+ // Event return value types are not checked by the WebExtension framework,
+ // manual verification is required.
+ if (
+ results[0].templateInfo.download_expiry_date.timestamp &&
+ Number.isInteger(
+ results[0].templateInfo.download_expiry_date.timestamp
+ )
+ ) {
+ upload.downloadExpiryDate =
+ results[0].templateInfo.download_expiry_date;
+ } else {
+ console.warn(
+ "Invalid CloudFileTemplateInfo.download_expiry_date object, the timestamp property is required and it must be of type integer."
+ );
+ }
+ }
+ }
+
+ upload.url = results[0].url;
+
+ return { ...upload };
+ }
+
+ this._uploads.delete(id);
+ throw Components.Exception(
+ `Upload error: Missing cloudFile.onFileUpload listener for ${this.extension.id} (or it is not returning url or aborted)`,
+ cloudFileAccounts.constants.uploadErr
+ );
+ }
+
+ /**
+ * Checks if the url of the given upload has been used already.
+ *
+ * @param {CloudFileUpload} cloudFileUpload
+ */
+ isReusedUpload(cloudFileUpload) {
+ if (!cloudFileUpload) {
+ return false;
+ }
+
+ // Find matching url in known uploads and check if it is immutable.
+ let isImmutableUrl = url => {
+ return [...this._uploads.values()].some(u => u.immutable && u.url == url);
+ };
+
+ // Check all open windows if the url is used elsewhere.
+ let isDuplicateUrl = url => {
+ let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+ if (composeWindows.length == 0) {
+ return false;
+ }
+ let countsPerWindow = composeWindows.map(window => {
+ let bucket = window.document.getElementById("attachmentBucket");
+ if (!bucket) {
+ return 0;
+ }
+ return [...bucket.childNodes].filter(
+ node => node.attachment.contentLocation == url
+ ).length;
+ });
+
+ return countsPerWindow.reduce((prev, curr) => prev + curr) > 1;
+ };
+
+ return (
+ isImmutableUrl(cloudFileUpload.url) || isDuplicateUrl(cloudFileUpload.url)
+ );
+ }
+
+ /**
+ * Initiate a WebExtension cloudFile rename by triggering an onFileRename event.
+ *
+ * @param {object} window Window object of the window, where the upload has
+ * been initiated. Must be null, if the window is not supported by the
+ * WebExtension windows/tabs API. Currently, this should only be set by the
+ * compose window.
+ * @param {Integer} uploadId Id of the uploaded file.
+ * @param {string} newName The requested new name of the file.
+ * @returns {CloudFileUpload} Information about the renamed file.
+ */
+ async renameFile(window, uploadId, newName) {
+ if (!this._uploads.has(uploadId)) {
+ throw Components.Exception(
+ "Rename error.",
+ cloudFileAccounts.constants.renameErr
+ );
+ }
+
+ let upload = this._uploads.get(uploadId);
+ let results;
+ try {
+ results = await this.extension.emit(
+ "renameFile",
+ this,
+ uploadId,
+ newName,
+ window
+ );
+ } catch (ex) {
+ throw Components.Exception(
+ `Rename error: ${ex.message}`,
+ cloudFileAccounts.constants.renameErr
+ );
+ }
+
+ if (!results || results.length == 0) {
+ throw Components.Exception(
+ `Rename error: Missing cloudFile.onFileRename listener for ${this.extension.id}`,
+ cloudFileAccounts.constants.renameNotSupported
+ );
+ }
+
+ if (results[0]) {
+ if (results[0].error) {
+ if (typeof results[0].error == "boolean") {
+ throw Components.Exception(
+ "Rename error.",
+ cloudFileAccounts.constants.renameErr
+ );
+ } else {
+ throw Components.Exception(
+ results[0].error,
+ cloudFileAccounts.constants.renameErrWithCustomMessage
+ );
+ }
+ }
+
+ if (results[0].url) {
+ upload.url = results[0].url;
+ }
+ }
+
+ upload.name = newName;
+ return upload;
+ }
+
+ urlForFile(uploadId) {
+ return this._uploads.get(uploadId).url;
+ }
+
+ /**
+ * Cancel a WebExtension cloudFile upload by triggering an onFileUploadAbort
+ * event.
+ *
+ * @param {object} window Window object of the window, where the upload has
+ * been initiated. Must be null, if the window is not supported by the
+ * WebExtension windows/tabs API. Currently, this should only be set by the
+ * compose window.
+ * @param {nsIFile} file File to be uploaded.
+ */
+ async cancelFileUpload(window, file) {
+ let path = file.path;
+ let uploadId = -1;
+ for (let upload of this._uploads.values()) {
+ if (!upload.url && upload.path == path) {
+ uploadId = upload.id;
+ break;
+ }
+ }
+
+ if (uploadId == -1) {
+ console.error(`No upload in progress for file ${file.path}`);
+ return false;
+ }
+
+ let result = await this.extension.emit(
+ "uploadAbort",
+ this,
+ uploadId,
+ window
+ );
+ if (result && result.length > 0) {
+ return true;
+ }
+
+ console.error(
+ `Missing cloudFile.onFileUploadAbort listener for ${this.extension.id}`
+ );
+ return false;
+ }
+
+ getPreviousUploads() {
+ return [...this._uploads.values()].map(u => {
+ return { ...u };
+ });
+ }
+
+ /**
+ * Delete a WebExtension cloudFile upload by triggering an onFileDeleted event.
+ *
+ * @param {object} window Window object of the window, where the upload has
+ * been initiated. Must be null, if the window is not supported by the
+ * WebExtension windows/tabs API. Currently, this should only be set by the
+ * compose window.
+ * @param {Integer} uploadId Id of the uploaded file.
+ */
+ async deleteFile(window, uploadId) {
+ if (!this.extension.emitter.has("deleteFile")) {
+ throw Components.Exception(
+ `Delete error: Missing cloudFile.onFileDeleted listener for ${this.extension.id}`,
+ cloudFileAccounts.constants.deleteErr
+ );
+ }
+
+ try {
+ if (this._uploads.has(uploadId)) {
+ let upload = this._uploads.get(uploadId);
+ if (!this.isReusedUpload(upload)) {
+ await this.extension.emit("deleteFile", this, uploadId, window);
+ this._uploads.delete(uploadId);
+ }
+ }
+ } catch (ex) {
+ throw Components.Exception(
+ `Delete error: ${ex.message}`,
+ cloudFileAccounts.constants.deleteErr
+ );
+ }
+ }
+}
+
+function convertCloudFileAccount(nativeAccount) {
+ return {
+ id: nativeAccount.accountKey,
+ name: nativeAccount.displayName,
+ configured: nativeAccount.configured,
+ uploadSizeLimit: nativeAccount.fileUploadSizeLimit,
+ spaceRemaining: nativeAccount.remainingFileSpace,
+ spaceUsed: nativeAccount.fileSpaceUsed,
+ managementUrl: nativeAccount.managementURL,
+ };
+}
+
+this.cloudFile = class extends ExtensionAPIPersistent {
+ get providerType() {
+ return `ext-${this.extension.id}`;
+ }
+
+ onManifestEntry(entryName) {
+ if (entryName == "cloud_file") {
+ let { extension } = this;
+ cloudFileAccounts.registerProvider(this.providerType, {
+ type: this.providerType,
+ displayName: extension.manifest.cloud_file.name,
+ get iconURL() {
+ if (extension.manifest.icons) {
+ let { icon } = ExtensionParent.IconDetails.getPreferredIcon(
+ extension.manifest.icons,
+ extension,
+ 32
+ );
+ return extension.baseURI.resolve(icon);
+ }
+ return "chrome://messenger/content/extension.svg";
+ },
+ initAccount(accountKey) {
+ return new CloudFileAccount(accountKey, extension);
+ },
+ });
+ }
+ }
+
+ onShutdown(isAppShutdown) {
+ if (isAppShutdown) {
+ return;
+ }
+ cloudFileAccounts.unregisterProvider(this.providerType);
+ }
+
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onFileUpload({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(
+ _event,
+ account,
+ { id, name, data },
+ tab,
+ relatedFileInfo
+ ) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ tab = tab ? tabManager.convert(tab) : null;
+ account = convertCloudFileAccount(account);
+ return fire.async(account, { id, name, data }, tab, relatedFileInfo);
+ }
+ extension.on("uploadFile", listener);
+ return {
+ unregister: () => {
+ extension.off("uploadFile", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+
+ onFileUploadAbort({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(_event, account, id, tab) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ tab = tab ? tabManager.convert(tab) : null;
+ account = convertCloudFileAccount(account);
+ return fire.async(account, id, tab);
+ }
+ extension.on("uploadAbort", listener);
+ return {
+ unregister: () => {
+ extension.off("uploadAbort", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+
+ onFileRename({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(_event, account, id, newName, tab) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ tab = tab ? tabManager.convert(tab) : null;
+ account = convertCloudFileAccount(account);
+ return fire.async(account, id, newName, tab);
+ }
+ extension.on("renameFile", listener);
+ return {
+ unregister: () => {
+ extension.off("renameFile", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+
+ onFileDeleted({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(_event, account, id, tab) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ tab = tab ? tabManager.convert(tab) : null;
+ account = convertCloudFileAccount(account);
+ return fire.async(account, id, tab);
+ }
+ extension.on("deleteFile", listener);
+ return {
+ unregister: () => {
+ extension.off("deleteFile", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+
+ onAccountAdded({ context, fire }) {
+ const self = this;
+ async function listener(_event, nativeAccount) {
+ if (nativeAccount.type != self.providerType) {
+ return null;
+ }
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ return fire.async(convertCloudFileAccount(nativeAccount));
+ }
+ cloudFileAccounts.on("accountAdded", listener);
+ return {
+ unregister: () => {
+ cloudFileAccounts.off("accountAdded", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+
+ onAccountDeleted({ context, fire }) {
+ const self = this;
+ async function listener(_event, key, type) {
+ if (self.providerType != type) {
+ return null;
+ }
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ return fire.async(key);
+ }
+ cloudFileAccounts.on("accountDeleted", listener);
+ return {
+ unregister: () => {
+ cloudFileAccounts.off("accountDeleted", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ let self = this;
+
+ return {
+ cloudFile: {
+ onFileUpload: new EventManager({
+ context,
+ module: "cloudFile",
+ event: "onFileUpload",
+ extensionApi: this,
+ }).api(),
+
+ onFileUploadAbort: new EventManager({
+ context,
+ module: "cloudFile",
+ event: "onFileUploadAbort",
+ extensionApi: this,
+ }).api(),
+
+ onFileRename: new EventManager({
+ context,
+ module: "cloudFile",
+ event: "onFileRename",
+ extensionApi: this,
+ }).api(),
+
+ onFileDeleted: new EventManager({
+ context,
+ module: "cloudFile",
+ event: "onFileDeleted",
+ extensionApi: this,
+ }).api(),
+
+ onAccountAdded: new EventManager({
+ context,
+ module: "cloudFile",
+ event: "onAccountAdded",
+ extensionApi: this,
+ }).api(),
+
+ onAccountDeleted: new EventManager({
+ context,
+ module: "cloudFile",
+ event: "onAccountDeleted",
+ extensionApi: this,
+ }).api(),
+
+ async getAccount(accountId) {
+ let account = cloudFileAccounts.getAccount(accountId);
+
+ if (!account || account.type != self.providerType) {
+ return undefined;
+ }
+
+ return convertCloudFileAccount(account);
+ },
+
+ async getAllAccounts() {
+ return cloudFileAccounts
+ .getAccountsForType(self.providerType)
+ .map(convertCloudFileAccount);
+ },
+
+ async updateAccount(accountId, updateProperties) {
+ let account = cloudFileAccounts.getAccount(accountId);
+
+ if (!account || account.type != self.providerType) {
+ return undefined;
+ }
+ if (updateProperties.configured !== null) {
+ account.configured = updateProperties.configured;
+ }
+ if (updateProperties.uploadSizeLimit !== null) {
+ account.quota.uploadSizeLimit = updateProperties.uploadSizeLimit;
+ }
+ if (updateProperties.spaceRemaining !== null) {
+ account.quota.spaceRemaining = updateProperties.spaceRemaining;
+ }
+ if (updateProperties.spaceUsed !== null) {
+ account.quota.spaceUsed = updateProperties.spaceUsed;
+ }
+ if (updateProperties.managementUrl !== null) {
+ account.managementURL = updateProperties.managementUrl;
+ }
+
+ return convertCloudFileAccount(account);
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-commands.js b/comm/mail/components/extensions/parent/ext-commands.js
new file mode 100644
index 0000000000..309793b7fa
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-commands.js
@@ -0,0 +1,103 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailExtensionShortcuts",
+ "resource:///modules/MailExtensionShortcuts.jsm"
+);
+
+this.commands = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onCommand({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(eventName, commandName) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ let tab = tabManager.convert(tabTracker.activeTab);
+ fire.async(commandName, tab);
+ }
+ this.on("command", listener);
+ return {
+ unregister: () => {
+ this.off("command", listener);
+ },
+ convert(_fire, _context) {
+ fire = _fire;
+ context = _context;
+ },
+ };
+ },
+ onChanged({ context, fire }) {
+ async function listener(eventName, changeInfo) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(changeInfo);
+ }
+ this.on("shortcutChanged", listener);
+ return {
+ unregister: () => {
+ this.off("shortcutChanged", listener);
+ },
+ convert(_fire, _context) {
+ fire = _fire;
+ context = _context;
+ },
+ };
+ },
+ };
+
+ static onUninstall(extensionId) {
+ return MailExtensionShortcuts.removeCommandsFromStorage(extensionId);
+ }
+
+ async onManifestEntry(entryName) {
+ let shortcuts = new MailExtensionShortcuts({
+ extension: this.extension,
+ onCommand: name => this.emit("command", name),
+ onShortcutChanged: changeInfo => this.emit("shortcutChanged", changeInfo),
+ });
+ this.extension.shortcuts = shortcuts;
+ await shortcuts.loadCommands();
+ await shortcuts.register();
+ }
+
+ onShutdown() {
+ this.extension.shortcuts.unregister();
+ }
+
+ getAPI(context) {
+ return {
+ commands: {
+ getAll: () => this.extension.shortcuts.allCommands(),
+ update: args => this.extension.shortcuts.updateCommand(args),
+ reset: name => this.extension.shortcuts.resetCommand(name),
+ onCommand: new EventManager({
+ context,
+ module: "commands",
+ event: "onCommand",
+ inputHandling: true,
+ extensionApi: this,
+ }).api(),
+ onChanged: new EventManager({
+ context,
+ module: "commands",
+ event: "onChanged",
+ extensionApi: this,
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-compose.js b/comm/mail/components/extensions/parent/ext-compose.js
new file mode 100644
index 0000000000..33a52c5e08
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-compose.js
@@ -0,0 +1,1703 @@
+/* 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/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["IOUtils", "PathUtils"]);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailServices",
+ "resource:///modules/MailServices.jsm"
+);
+
+var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+let { MsgUtils } = ChromeUtils.import(
+ "resource:///modules/MimeMessageUtils.jsm"
+);
+let parserUtils = Cc["@mozilla.org/parserutils;1"].getService(
+ Ci.nsIParserUtils
+);
+
+// eslint-disable-next-line mozilla/reject-importGlobalProperties
+Cu.importGlobalProperties(["File"]);
+
+const deliveryFormats = [
+ { id: Ci.nsIMsgCompSendFormat.Auto, value: "auto" },
+ { id: Ci.nsIMsgCompSendFormat.PlainText, value: "plaintext" },
+ { id: Ci.nsIMsgCompSendFormat.HTML, value: "html" },
+ { id: Ci.nsIMsgCompSendFormat.Both, value: "both" },
+];
+
+async function parseComposeRecipientList(
+ list,
+ requireSingleValidEmail = false
+) {
+ if (!list) {
+ return list;
+ }
+
+ function isValidAddress(address) {
+ return address.includes("@", 1) && !address.endsWith("@");
+ }
+
+ // A ComposeRecipientList could be just a single ComposeRecipient.
+ if (!Array.isArray(list)) {
+ list = [list];
+ }
+
+ let recipients = [];
+ for (let recipient of list) {
+ if (typeof recipient == "string") {
+ let addressObjects =
+ MailServices.headerParser.makeFromDisplayAddress(recipient);
+
+ for (let ao of addressObjects) {
+ if (requireSingleValidEmail && !isValidAddress(ao.email)) {
+ throw new ExtensionError(`Invalid address: ${ao.email}`);
+ }
+ recipients.push(
+ MailServices.headerParser.makeMimeAddress(ao.name, ao.email)
+ );
+ }
+ continue;
+ }
+ if (!("addressBookCache" in this)) {
+ await extensions.asyncLoadModule("addressBook");
+ }
+ if (recipient.type == "contact") {
+ let contactNode = this.addressBookCache.findContactById(recipient.id);
+
+ if (
+ requireSingleValidEmail &&
+ !isValidAddress(contactNode.item.primaryEmail)
+ ) {
+ throw new ExtensionError(
+ `Contact does not have a valid email address: ${recipient.id}`
+ );
+ }
+ recipients.push(
+ MailServices.headerParser.makeMimeAddress(
+ contactNode.item.displayName,
+ contactNode.item.primaryEmail
+ )
+ );
+ } else {
+ if (requireSingleValidEmail) {
+ throw new ExtensionError("Mailing list not allowed.");
+ }
+
+ let mailingListNode = this.addressBookCache.findMailingListById(
+ recipient.id
+ );
+ recipients.push(
+ MailServices.headerParser.makeMimeAddress(
+ mailingListNode.item.dirName,
+ mailingListNode.item.description || mailingListNode.item.dirName
+ )
+ );
+ }
+ }
+ if (requireSingleValidEmail && recipients.length != 1) {
+ throw new ExtensionError(
+ `Exactly one address instead of ${recipients.length} is required.`
+ );
+ }
+ return recipients.join(",");
+}
+
+function composeWindowIsReady(composeWindow) {
+ return new Promise(resolve => {
+ if (composeWindow.composeEditorReady) {
+ resolve();
+ return;
+ }
+ composeWindow.addEventListener("compose-editor-ready", resolve, {
+ once: true,
+ });
+ });
+}
+
+async function openComposeWindow(relatedMessageId, type, details, extension) {
+ let format = Ci.nsIMsgCompFormat.Default;
+ let identity = null;
+
+ if (details) {
+ if (details.isPlainText != null) {
+ format = details.isPlainText
+ ? Ci.nsIMsgCompFormat.PlainText
+ : Ci.nsIMsgCompFormat.HTML;
+ } else {
+ // If none or both of details.body and details.plainTextBody are given, the
+ // default compose format will be used.
+ if (details.body != null && details.plainTextBody == null) {
+ format = Ci.nsIMsgCompFormat.HTML;
+ }
+ if (details.plainTextBody != null && details.body == null) {
+ format = Ci.nsIMsgCompFormat.PlainText;
+ }
+ }
+
+ if (details.identityId != null) {
+ if (!extension.hasPermission("accountsRead")) {
+ throw new ExtensionError(
+ 'Using identities requires the "accountsRead" permission'
+ );
+ }
+
+ identity = MailServices.accounts.allIdentities.find(
+ i => i.key == details.identityId
+ );
+ if (!identity) {
+ throw new ExtensionError(`Identity not found: ${details.identityId}`);
+ }
+ }
+ }
+
+ // ForwardInline is totally broken, see bug 1513824. Fake it 'til we make it.
+ if (
+ [
+ Ci.nsIMsgCompType.ForwardInline,
+ Ci.nsIMsgCompType.Redirect,
+ Ci.nsIMsgCompType.EditAsNew,
+ Ci.nsIMsgCompType.Template,
+ ].includes(type)
+ ) {
+ let msgHdr = null;
+ let msgURI = null;
+ if (relatedMessageId) {
+ msgHdr = messageTracker.getMessage(relatedMessageId);
+ msgURI = msgHdr.folder.getUriForMsg(msgHdr);
+ }
+
+ // For the types in this code path, OpenComposeWindow only uses
+ // nsIMsgCompFormat.Default or OppositeOfDefault. Check which is needed.
+ // See https://hg.mozilla.org/comm-central/file/592fb5c396ebbb75d4acd1f1287a26f56f4164b3/mailnews/compose/src/nsMsgComposeService.cpp#l395
+ if (format != Ci.nsIMsgCompFormat.Default) {
+ // The mimeConverter used in this code path is not setting any format but
+ // defaults to plaintext if no identity and also no default account is set.
+ // The "mail.identity.default.compose_html" preference is NOT used.
+ let usedIdentity =
+ identity || MailServices.accounts.defaultAccount?.defaultIdentity;
+ let defaultFormat = usedIdentity?.composeHtml
+ ? Ci.nsIMsgCompFormat.HTML
+ : Ci.nsIMsgCompFormat.PlainText;
+ format =
+ format == defaultFormat
+ ? Ci.nsIMsgCompFormat.Default
+ : Ci.nsIMsgCompFormat.OppositeOfDefault;
+ }
+
+ let composeWindowPromise = new Promise(resolve => {
+ function listener(event) {
+ let composeWindow = event.target.ownerGlobal;
+ // Skip if this window has been processed already. This already helps
+ // a lot to assign the opened windows in the correct order to the
+ // OpenCompomposeWindow calls.
+ if (composeWindowTracker.has(composeWindow)) {
+ return;
+ }
+ // Do a few more checks to make sure we are looking at the expected
+ // window. This is still a hack. We need to make OpenCompomposeWindow
+ // actually return the opened window.
+ let _msgURI = composeWindow.gMsgCompose.originalMsgURI;
+ let _type = composeWindow.gComposeType;
+ if (_msgURI == msgURI && _type == type) {
+ composeWindowTracker.add(composeWindow);
+ windowTracker.removeListener("compose-editor-ready", listener);
+ resolve(composeWindow);
+ }
+ }
+ windowTracker.addListener("compose-editor-ready", listener);
+ });
+ MailServices.compose.OpenComposeWindow(
+ null,
+ msgHdr,
+ msgURI,
+ type,
+ format,
+ identity,
+ null,
+ null
+ );
+ let composeWindow = await composeWindowPromise;
+
+ if (details) {
+ await setComposeDetails(composeWindow, details, extension);
+ if (details.attachments != null) {
+ let attachmentData = [];
+ for (let data of details.attachments) {
+ attachmentData.push(await createAttachment(data));
+ }
+ await AddAttachmentsToWindow(composeWindow, attachmentData);
+ }
+ }
+ composeWindow.gContentChanged = false;
+ return composeWindow;
+ }
+
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+ let composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ if (relatedMessageId) {
+ let msgHdr = messageTracker.getMessage(relatedMessageId);
+ params.originalMsgURI = msgHdr.folder.getUriForMsg(msgHdr);
+ }
+
+ params.type = type;
+ params.format = format;
+ if (identity) {
+ params.identity = identity;
+ }
+
+ params.composeFields = composeFields;
+ let composeWindow = Services.ww.openWindow(
+ null,
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml",
+ "_blank",
+ "all,chrome,dialog=no,status,toolbar",
+ params
+ );
+ await composeWindowIsReady(composeWindow);
+
+ // Not all details can be set with params for all types, so some need an extra
+ // call to setComposeDetails here. Since we have to use setComposeDetails for
+ // the EditAsNew code path, unify API behavior by always calling it here too.
+ if (details) {
+ await setComposeDetails(composeWindow, details, extension);
+ if (details.attachments != null) {
+ let attachmentData = [];
+ for (let data of details.attachments) {
+ attachmentData.push(await createAttachment(data));
+ }
+ await AddAttachmentsToWindow(composeWindow, attachmentData);
+ }
+ }
+ composeWindow.gContentChanged = false;
+ return composeWindow;
+}
+
+/**
+ * Converts "\r\n" line breaks to "\n" and removes trailing line breaks.
+ *
+ * @param {string} content - original content
+ * @returns {string} - trimmed content
+ */
+function trimContent(content) {
+ let data = content.replaceAll("\r\n", "\n").split("\n");
+ while (data[data.length - 1] == "") {
+ data.pop();
+ }
+ return data.join("\n");
+}
+
+/**
+ * Get the compose details of the requested compose window.
+ *
+ * @param {DOMWindow} composeWindow
+ * @param {ExtensionData} extension
+ * @returns {ComposeDetails}
+ *
+ * @see mail/components/extensions/schemas/compose.json
+ */
+async function getComposeDetails(composeWindow, extension) {
+ let composeFields = composeWindow.GetComposeDetails();
+ let editor = composeWindow.GetCurrentEditor();
+
+ let type;
+ // check all known nsIMsgComposeParams
+ switch (composeWindow.gComposeType) {
+ case Ci.nsIMsgCompType.Draft:
+ type = "draft";
+ break;
+ case Ci.nsIMsgCompType.New:
+ case Ci.nsIMsgCompType.Template:
+ case Ci.nsIMsgCompType.MailToUrl:
+ case Ci.nsIMsgCompType.EditAsNew:
+ case Ci.nsIMsgCompType.EditTemplate:
+ case Ci.nsIMsgCompType.NewsPost:
+ type = "new";
+ break;
+ case Ci.nsIMsgCompType.Reply:
+ case Ci.nsIMsgCompType.ReplyAll:
+ case Ci.nsIMsgCompType.ReplyToSender:
+ case Ci.nsIMsgCompType.ReplyToGroup:
+ case Ci.nsIMsgCompType.ReplyToSenderAndGroup:
+ case Ci.nsIMsgCompType.ReplyWithTemplate:
+ case Ci.nsIMsgCompType.ReplyToList:
+ type = "reply";
+ break;
+ case Ci.nsIMsgCompType.ForwardAsAttachment:
+ case Ci.nsIMsgCompType.ForwardInline:
+ type = "forward";
+ break;
+ case Ci.nsIMsgCompType.Redirect:
+ type = "redirect";
+ break;
+ }
+
+ let relatedMessageId = null;
+ if (composeWindow.gMsgCompose.originalMsgURI) {
+ try {
+ // This throws for messages opened from file and then being replied to.
+ let relatedMsgHdr = composeWindow.gMessenger.msgHdrFromURI(
+ composeWindow.gMsgCompose.originalMsgURI
+ );
+ relatedMessageId = messageTracker.getId(relatedMsgHdr);
+ } catch (ex) {
+ // We are currently unable to get the fake msgHdr from the uri of messages
+ // opened from file.
+ }
+ }
+
+ let customHeaders = [...composeFields.headerNames]
+ .map(h => h.toLowerCase())
+ .filter(h => h.startsWith("x-"))
+ .map(h => {
+ return {
+ // All-lower-case-names are ugly, so capitalize first letters.
+ name: h.replace(/(^|-)[a-z]/g, function (match) {
+ return match.toUpperCase();
+ }),
+ value: composeFields.getHeader(h),
+ };
+ });
+
+ // We have two file carbon copy settings: fcc and fcc2. fcc allows to override
+ // the default identity fcc and fcc2 is coupled to the UI selection.
+ let overrideDefaultFcc = false;
+ if (composeFields.fcc && composeFields.fcc != "") {
+ overrideDefaultFcc = true;
+ }
+ let overrideDefaultFccFolder = "";
+ if (overrideDefaultFcc && !composeFields.fcc.startsWith("nocopy://")) {
+ let folder = MailUtils.getExistingFolder(composeFields.fcc);
+ if (folder) {
+ overrideDefaultFccFolder = convertFolder(folder);
+ }
+ }
+ let additionalFccFolder = "";
+ if (composeFields.fcc2 && !composeFields.fcc2.startsWith("nocopy://")) {
+ let folder = MailUtils.getExistingFolder(composeFields.fcc2);
+ if (folder) {
+ additionalFccFolder = convertFolder(folder);
+ }
+ }
+
+ let deliveryFormat = composeWindow.IsHTMLEditor()
+ ? deliveryFormats.find(f => f.id == composeFields.deliveryFormat).value
+ : null;
+
+ let body = trimContent(
+ editor.outputToString("text/html", Ci.nsIDocumentEncoder.OutputRaw)
+ );
+ let plainTextBody;
+ if (composeWindow.IsHTMLEditor()) {
+ plainTextBody = trimContent(MsgUtils.convertToPlainText(body, true));
+ } else {
+ plainTextBody = parserUtils.convertToPlainText(
+ body,
+ Ci.nsIDocumentEncoder.OutputLFLineBreak,
+ 0
+ );
+ // Remove the extra new line at the end.
+ if (plainTextBody.endsWith("\n")) {
+ plainTextBody = plainTextBody.slice(0, -1);
+ }
+ }
+
+ let details = {
+ from: composeFields.splitRecipients(composeFields.from, false).shift(),
+ to: composeFields.splitRecipients(composeFields.to, false),
+ cc: composeFields.splitRecipients(composeFields.cc, false),
+ bcc: composeFields.splitRecipients(composeFields.bcc, false),
+ overrideDefaultFcc,
+ overrideDefaultFccFolder: overrideDefaultFcc
+ ? overrideDefaultFccFolder
+ : null,
+ additionalFccFolder,
+ type,
+ relatedMessageId,
+ replyTo: composeFields.splitRecipients(composeFields.replyTo, false),
+ followupTo: composeFields.splitRecipients(composeFields.followupTo, false),
+ newsgroups: composeFields.newsgroups
+ ? composeFields.newsgroups.split(",")
+ : [],
+ subject: composeFields.subject,
+ isPlainText: !composeWindow.IsHTMLEditor(),
+ deliveryFormat,
+ body,
+ plainTextBody,
+ customHeaders,
+ priority: composeFields.priority.toLowerCase() || "normal",
+ returnReceipt: composeFields.returnReceipt,
+ deliveryStatusNotification: composeFields.DSN,
+ attachVCard: composeFields.attachVCard,
+ };
+ if (extension.hasPermission("accountsRead")) {
+ details.identityId = composeWindow.getCurrentIdentityKey();
+ }
+ return details;
+}
+
+async function setFromField(composeWindow, details, extension) {
+ if (!details || details.from == null) {
+ return;
+ }
+
+ let from;
+ // Re-throw exceptions from parseComposeRecipientList with a prefix to
+ // minimize developers debugging time and make clear where restrictions are
+ // coming from.
+ try {
+ from = await parseComposeRecipientList(details.from, true);
+ } catch (ex) {
+ throw new ExtensionError(`ComposeDetails.from: ${ex.message}`);
+ }
+ if (!from) {
+ throw new ExtensionError(
+ "ComposeDetails.from: Address must not be set to an empty string."
+ );
+ }
+
+ let identityList = composeWindow.document.getElementById("msgIdentity");
+ // Make the from field editable only, if from differs from the currently shown identity.
+ if (from != identityList.value) {
+ let activeElement = composeWindow.document.activeElement;
+ // Manually update from, using the same approach used in
+ // https://hg.mozilla.org/comm-central/file/1283451c02926e2b7506a6450445b81f6d076f89/mail/components/compose/content/MsgComposeCommands.js#l3621
+ composeWindow.MakeFromFieldEditable(true);
+ identityList.value = from;
+ activeElement.focus();
+ }
+}
+
+/**
+ * Updates the compose details of the specified compose window, overwriting any
+ * property given in the details object.
+ *
+ * @param {DOMWindow} composeWindow
+ * @param {ComposeDetails} details - compose details to update the composer with
+ * @param {ExtensionData} extension
+ *
+ * @see mail/components/extensions/schemas/compose.json
+ */
+async function setComposeDetails(composeWindow, details, extension) {
+ let activeElement = composeWindow.document.activeElement;
+
+ // Check if conflicting formats have been specified.
+ if (
+ details.isPlainText === true &&
+ details.body != null &&
+ details.plainTextBody == null
+ ) {
+ throw new ExtensionError(
+ "Conflicting format setting: isPlainText = true and providing a body but no plainTextBody."
+ );
+ }
+ if (
+ details.isPlainText === false &&
+ details.body == null &&
+ details.plainTextBody != null
+ ) {
+ throw new ExtensionError(
+ "Conflicting format setting: isPlainText = false and providing a plainTextBody but no body."
+ );
+ }
+
+ // Remove any unsupported body type. Otherwise, this will throw an
+ // NS_UNEXPECTED_ERROR later. Note: setComposeDetails cannot change the compose
+ // format, details.isPlainText is ignored.
+ if (composeWindow.IsHTMLEditor()) {
+ delete details.plainTextBody;
+ } else {
+ delete details.body;
+ }
+
+ if (details.identityId) {
+ if (!extension.hasPermission("accountsRead")) {
+ throw new ExtensionError(
+ 'Using identities requires the "accountsRead" permission'
+ );
+ }
+
+ let identity = MailServices.accounts.allIdentities.find(
+ i => i.key == details.identityId
+ );
+ if (!identity) {
+ throw new ExtensionError(`Identity not found: ${details.identityId}`);
+ }
+ let identityElement = composeWindow.document.getElementById("msgIdentity");
+ identityElement.selectedItem = [
+ ...identityElement.childNodes[0].childNodes,
+ ].find(e => e.getAttribute("identitykey") == details.identityId);
+ composeWindow.LoadIdentity(false);
+ }
+ for (let field of ["to", "cc", "bcc", "replyTo", "followupTo"]) {
+ if (field in details) {
+ details[field] = await parseComposeRecipientList(details[field]);
+ }
+ }
+ if (Array.isArray(details.newsgroups)) {
+ details.newsgroups = details.newsgroups.join(",");
+ }
+
+ composeWindow.SetComposeDetails(details);
+ await setFromField(composeWindow, details, extension);
+
+ // Set file carbon copy values.
+ if (details.overrideDefaultFcc === false) {
+ composeWindow.gMsgCompose.compFields.fcc = "";
+ } else if (details.overrideDefaultFccFolder != null) {
+ // Override identity fcc with enforced value.
+ if (details.overrideDefaultFccFolder) {
+ let uri = folderPathToURI(
+ details.overrideDefaultFccFolder.accountId,
+ details.overrideDefaultFccFolder.path
+ );
+ let folder = MailUtils.getExistingFolder(uri);
+ if (folder) {
+ composeWindow.gMsgCompose.compFields.fcc = uri;
+ } else {
+ throw new ExtensionError(
+ `Invalid MailFolder: {accountId:${details.overrideDefaultFccFolder.accountId}, path:${details.overrideDefaultFccFolder.path}}`
+ );
+ }
+ } else {
+ composeWindow.gMsgCompose.compFields.fcc = "nocopy://";
+ }
+ } else if (
+ details.overrideDefaultFcc === true &&
+ composeWindow.gMsgCompose.compFields.fcc == ""
+ ) {
+ throw new ExtensionError(
+ `Setting overrideDefaultFcc to true requires setting overrideDefaultFccFolder as well`
+ );
+ }
+
+ if (details.additionalFccFolder != null) {
+ if (details.additionalFccFolder) {
+ let uri = folderPathToURI(
+ details.additionalFccFolder.accountId,
+ details.additionalFccFolder.path
+ );
+ let folder = MailUtils.getExistingFolder(uri);
+ if (folder) {
+ composeWindow.gMsgCompose.compFields.fcc2 = uri;
+ } else {
+ throw new ExtensionError(
+ `Invalid MailFolder: {accountId:${details.additionalFccFolder.accountId}, path:${details.additionalFccFolder.path}}`
+ );
+ }
+ } else {
+ composeWindow.gMsgCompose.compFields.fcc2 = "";
+ }
+ }
+
+ // Update custom headers, if specified.
+ if (details.customHeaders) {
+ let newHeaderNames = details.customHeaders.map(h => h.name.toUpperCase());
+ let obsoleteHeaderNames = [
+ ...composeWindow.gMsgCompose.compFields.headerNames,
+ ]
+ .map(h => h.toUpperCase())
+ .filter(h => h.startsWith("X-") && !newHeaderNames.hasOwnProperty(h));
+
+ for (let headerName of obsoleteHeaderNames) {
+ composeWindow.gMsgCompose.compFields.deleteHeader(headerName);
+ }
+ for (let { name, value } of details.customHeaders) {
+ composeWindow.gMsgCompose.compFields.setHeader(name, value);
+ }
+ }
+
+ // Update priorities. The enum in the schema defines all allowed values, no
+ // need to validate here.
+ if (details.priority) {
+ if (details.priority == "normal") {
+ composeWindow.gMsgCompose.compFields.priority = "";
+ } else {
+ composeWindow.gMsgCompose.compFields.priority =
+ details.priority[0].toUpperCase() + details.priority.slice(1);
+ }
+ composeWindow.updatePriorityToolbarButton(
+ composeWindow.gMsgCompose.compFields.priority
+ );
+ }
+
+ // Update receipt notifications.
+ if (details.returnReceipt != null) {
+ composeWindow.ToggleReturnReceipt(details.returnReceipt);
+ }
+
+ if (
+ details.deliveryStatusNotification != null &&
+ details.deliveryStatusNotification !=
+ composeWindow.gMsgCompose.compFields.DSN
+ ) {
+ let target = composeWindow.document.getElementById("dsnMenu");
+ composeWindow.ToggleDSN(target);
+ }
+
+ if (details.deliveryFormat && composeWindow.IsHTMLEditor()) {
+ // Do not throw when a deliveryFormat is set on a plaint text composer, because
+ // it is allowed to set ComposeDetails of an html composer onto a plain text
+ // composer (and automatically pick the plainText body). The deliveryFormat
+ // will be ignored.
+ composeWindow.gMsgCompose.compFields.deliveryFormat = deliveryFormats.find(
+ f => f.value == details.deliveryFormat
+ ).id;
+ composeWindow.initSendFormatMenu();
+ }
+
+ if (details.attachVCard != null) {
+ composeWindow.gMsgCompose.compFields.attachVCard = details.attachVCard;
+ composeWindow.gAttachVCardOptionChanged = true;
+ }
+
+ activeElement.focus();
+}
+
+async function fileURLForFile(file) {
+ let realFile = await getRealFileForFile(file);
+ return Services.io.newFileURI(realFile).spec;
+}
+
+async function createAttachment(data) {
+ let attachment = Cc[
+ "@mozilla.org/messengercompose/attachment;1"
+ ].createInstance(Ci.nsIMsgAttachment);
+
+ if (data.id) {
+ if (!composeAttachmentTracker.hasAttachment(data.id)) {
+ throw new ExtensionError(`Invalid attachment ID: ${data.id}`);
+ }
+
+ let { attachment: originalAttachment, window: originalWindow } =
+ composeAttachmentTracker.getAttachment(data.id);
+
+ let originalAttachmentItem =
+ originalWindow.gAttachmentBucket.findItemForAttachment(
+ originalAttachment
+ );
+
+ attachment.name = data.name || originalAttachment.name;
+ attachment.size = originalAttachment.size;
+ attachment.url = originalAttachment.url;
+
+ return {
+ attachment,
+ originalAttachment,
+ originalCloudFileAccount: originalAttachmentItem.cloudFileAccount,
+ originalCloudFileUpload: originalAttachmentItem.cloudFileUpload,
+ };
+ }
+
+ if (data.file) {
+ attachment.name = data.name || data.file.name;
+ attachment.size = data.file.size;
+ attachment.url = await fileURLForFile(data.file);
+ attachment.contentType = data.file.type;
+ return { attachment };
+ }
+
+ throw new ExtensionError(`Failed to create attachment.`);
+}
+
+async function AddAttachmentsToWindow(window, attachmentData) {
+ await window.AddAttachments(attachmentData.map(a => a.attachment));
+ // Check if an attachment has been cloned and the cloudFileUpload needs to be
+ // re-applied.
+ for (let entry of attachmentData) {
+ let addedAttachmentItem = window.gAttachmentBucket.findItemForAttachment(
+ entry.attachment
+ );
+ if (!addedAttachmentItem) {
+ continue;
+ }
+
+ if (
+ !entry.originalAttachment ||
+ !entry.originalCloudFileAccount ||
+ !entry.originalCloudFileUpload
+ ) {
+ continue;
+ }
+
+ let updateSettings = {
+ cloudFileAccount: entry.originalCloudFileAccount,
+ relatedCloudFileUpload: entry.originalCloudFileUpload,
+ };
+ if (entry.originalAttachment.name != entry.attachment.name) {
+ updateSettings.name = entry.attachment.name;
+ }
+
+ try {
+ await window.UpdateAttachment(addedAttachmentItem, updateSettings);
+ } catch (ex) {
+ throw new ExtensionError(ex.message);
+ }
+ }
+}
+
+var composeStates = {
+ _states: {
+ canSendNow: "cmd_sendNow",
+ canSendLater: "cmd_sendLater",
+ },
+
+ getStates(tab) {
+ let states = {};
+ for (let [state, command] of Object.entries(this._states)) {
+ state[state] = tab.nativeTab.defaultController.isCommandEnabled(command);
+ }
+ return states;
+ },
+
+ // Translate core states (commands) to API states.
+ convert(states) {
+ let converted = {};
+ for (let [state, command] of Object.entries(this._states)) {
+ if (states.hasOwnProperty(command)) {
+ converted[state] = states[command];
+ }
+ }
+ return converted;
+ },
+};
+
+class MsgOperationObserver {
+ constructor(composeWindow) {
+ this.composeWindow = composeWindow;
+ this.savedMessages = [];
+ this.headerMessageId = null;
+ this.deliveryCallbacks = null;
+ this.preparedCallbacks = null;
+ this.classifiedMessages = new Map();
+
+ // The preparedPromise fulfills when the message has been prepared and handed
+ // over to the send process.
+ this.preparedPromise = new Promise((resolve, reject) => {
+ this.preparedCallbacks = { resolve, reject };
+ });
+
+ // The deliveryPromise fulfills when the message has been saved/send.
+ this.deliveryPromise = new Promise((resolve, reject) => {
+ this.deliveryCallbacks = { resolve, reject };
+ });
+
+ Services.obs.addObserver(this, "mail:composeSendProgressStop");
+ this.composeWindow.gMsgCompose.addMsgSendListener(this);
+ MailServices.mfn.addListener(this, MailServices.mfn.msgsClassified);
+ this.composeWindow.addEventListener(
+ "compose-prepare-message-success",
+ event => this.preparedCallbacks.resolve(),
+ { once: true }
+ );
+ this.composeWindow.addEventListener(
+ "compose-prepare-message-failure",
+ event => this.preparedCallbacks.reject(event.detail.exception),
+ { once: true }
+ );
+ }
+
+ // Observer for mail:composeSendProgressStop.
+ observe(subject, topic, data) {
+ let { composeWindow } = subject.wrappedJSObject;
+ if (composeWindow == this.composeWindow) {
+ this.deliveryCallbacks.resolve();
+ }
+ }
+
+ // nsIMsgSendListener
+ onStartSending(msgID, msgSize) {}
+ onProgress(msgID, progress, progressMax) {}
+ onStatus(msgID, msg) {}
+ onStopSending(msgID, status, msg, returnFile) {
+ if (!Components.isSuccessCode(status)) {
+ this.deliveryCallbacks.reject(
+ new ExtensionError("Message operation failed")
+ );
+ return;
+ }
+ // In case of success, this is only called for sendNow, stating the
+ // headerMessageId of the outgoing message.
+ // The msgID starts with < and ends with > which is not used by the API.
+ this.headerMessageId = msgID.replace(/^<|>$/g, "");
+ }
+ onGetDraftFolderURI(msgID, folderURI) {
+ // Only called for save operations and sendLater. Collect messageIds and
+ // folders of saved messages.
+ let headerMessageId = msgID.replace(/^<|>$/g, "");
+ this.savedMessages.push(JSON.stringify({ headerMessageId, folderURI }));
+ }
+ onSendNotPerformed(msgID, status) {}
+ onTransportSecurityError(msgID, status, secInfo, location) {}
+
+ // Implementation for nsIMsgFolderListener::msgsClassified
+ msgsClassified(msgs, junkProcessed, traitProcessed) {
+ // Collect all msgHdrs added to folders during the current message operation.
+ for (let msgHdr of msgs) {
+ let key = JSON.stringify({
+ headerMessageId: msgHdr.messageId,
+ folderURI: msgHdr.folder.URI,
+ });
+ if (!this.classifiedMessages.has(key)) {
+ this.classifiedMessages.set(key, convertMessage(msgHdr));
+ }
+ }
+ }
+
+ /**
+ * @typedef MsgOperationInfo
+ * @property {string} headerMessageId - the id used in the "Message-Id" header
+ * of the outgoing message, only available for the "sendNow" mode
+ * @property {MessageHeader[]} messages - array of WebExtension MessageHeader
+ * objects, with information about saved messages (depends on fcc config)
+ * @see mail/components/extensions/schemas/compose.json
+ */
+
+ /**
+ * Returns a Promise, which resolves once the message operation has finished.
+ *
+ * @returns {Promise<MsgOperationInfo>} - Promise for information about the
+ * performed message operation.
+ */
+ async waitForOperation() {
+ try {
+ await Promise.all([this.deliveryPromise, this.preparedPromise]);
+ return {
+ messages: this.savedMessages
+ .map(m => this.classifiedMessages.get(m))
+ .filter(Boolean),
+ headerMessageId: this.headerMessageId,
+ };
+ } catch (ex) {
+ // In case of error, reject the pending delivery Promise.
+ this.deliveryCallbacks.reject();
+ throw ex;
+ } finally {
+ MailServices.mfn.removeListener(this);
+ Services.obs.removeObserver(this, "mail:composeSendProgressStop");
+ this.composeWindow?.gMsgCompose?.removeMsgSendListener(this);
+ }
+ }
+}
+
+/**
+ * @typedef MsgOperationReturnValue
+ * @property {string} headerMessageId - the id used in the "Message-Id" header
+ * of the outgoing message, only available for the "sendNow" mode
+ * @property {MessageHeader[]} messages - array of WebExtension MessageHeader
+ * objects, with information about saved messages (depends on fcc config)
+ * @see mail/components/extensions/schemas/compose.json
+ * @property {string} mode - the mode of the message operation
+ * @see mail/components/extensions/schemas/compose.json
+ */
+
+/**
+ * Executes the given save/send command. The returned Promise resolves once the
+ * message operation has finished.
+ *
+ * @returns {Promise<MsgOperationReturnValue>} - Promise for information about
+ * the performed message operation, which is passed to the WebExtension.
+ */
+async function goDoCommand(composeWindow, extension, mode) {
+ let commands = new Map([
+ ["draft", "cmd_saveAsDraft"],
+ ["template", "cmd_saveAsTemplate"],
+ ["sendNow", "cmd_sendNow"],
+ ["sendLater", "cmd_sendLater"],
+ ]);
+
+ if (!commands.has(mode)) {
+ throw new ExtensionError(`Unsupported mode: ${mode}`);
+ }
+
+ if (!composeWindow.defaultController.isCommandEnabled(commands.get(mode))) {
+ throw new ExtensionError(
+ `Message compose window not ready for the requested command`
+ );
+ }
+
+ let sendPromise = new Promise((resolve, reject) => {
+ let listener = {
+ onSuccess(window, mode, messages, headerMessageId) {
+ if (window == composeWindow) {
+ afterSaveSendEventTracker.removeListener(listener);
+ let info = { mode, messages };
+ if (mode == "sendNow") {
+ info.headerMessageId = headerMessageId;
+ }
+ resolve(info);
+ }
+ },
+ onFailure(window, mode, exception) {
+ if (window == composeWindow) {
+ afterSaveSendEventTracker.removeListener(listener);
+ reject(exception);
+ }
+ },
+ modes: [mode],
+ extension,
+ };
+ afterSaveSendEventTracker.addListener(listener);
+ });
+
+ // Initiate send.
+ switch (mode) {
+ case "draft":
+ composeWindow.SaveAsDraft();
+ break;
+ case "template":
+ composeWindow.SaveAsTemplate();
+ break;
+ case "sendNow":
+ composeWindow.SendMessage();
+ break;
+ case "sendLater":
+ composeWindow.SendMessageLater();
+ break;
+ }
+ return sendPromise;
+}
+
+var afterSaveSendEventTracker = {
+ listeners: new Set(),
+
+ addListener(listener) {
+ this.listeners.add(listener);
+ },
+ removeListener(listener) {
+ this.listeners.delete(listener);
+ },
+ async handleSuccess(window, mode, messages, headerMessageId) {
+ for (let listener of this.listeners) {
+ if (!listener.modes.includes(mode)) {
+ continue;
+ }
+ await listener.onSuccess(
+ window,
+ mode,
+ messages.map(message => {
+ // Strip data from MessageHeader if this extension doesn't have
+ // the required permission.
+ let clone = Object.assign({}, message);
+ if (!listener.extension.hasPermission("accountsRead")) {
+ delete clone.folders;
+ }
+ return clone;
+ }),
+ headerMessageId
+ );
+ }
+ },
+ async handleFailure(window, mode, exception) {
+ for (let listener of this.listeners) {
+ if (!listener.modes.includes(mode)) {
+ continue;
+ }
+ await listener.onFailure(window, mode, exception);
+ }
+ },
+
+ // Event handler for the "compose-prepare-message-start", which initiates a
+ // new message operation (send or save).
+ handleEvent(event) {
+ let composeWindow = event.target;
+ let msgType = event.detail.msgType;
+
+ let modes = new Map([
+ [Ci.nsIMsgCompDeliverMode.SaveAsDraft, "draft"],
+ [Ci.nsIMsgCompDeliverMode.SaveAsTemplate, "template"],
+ [Ci.nsIMsgCompDeliverMode.Now, "sendNow"],
+ [Ci.nsIMsgCompDeliverMode.Later, "sendLater"],
+ ]);
+ let mode = modes.get(msgType);
+
+ if (mode && this.listeners.size > 0) {
+ let msgOperationObserver = new MsgOperationObserver(composeWindow);
+ msgOperationObserver
+ .waitForOperation()
+ .then(msgOperationInfo =>
+ this.handleSuccess(
+ composeWindow,
+ mode,
+ msgOperationInfo.messages,
+ msgOperationInfo.headerMessageId
+ )
+ )
+ .catch(msgOperationException =>
+ this.handleFailure(composeWindow, mode, msgOperationException)
+ );
+ }
+ },
+};
+windowTracker.addListener(
+ "compose-prepare-message-start",
+ afterSaveSendEventTracker
+);
+
+var beforeSendEventTracker = {
+ listeners: new Set(),
+
+ addListener(listener) {
+ this.listeners.add(listener);
+ if (this.listeners.size == 1) {
+ windowTracker.addListener("beforesend", this);
+ }
+ },
+ removeListener(listener) {
+ this.listeners.delete(listener);
+ if (this.listeners.size == 0) {
+ windowTracker.removeListener("beforesend", this);
+ }
+ },
+ async handleEvent(event) {
+ event.preventDefault();
+
+ let sendPromise = event.detail;
+ let composeWindow = event.target;
+ await composeWindowIsReady(composeWindow);
+ composeWindow.ToggleWindowLock(true);
+
+ // Send process waits till sendPromise.resolve() or sendPromise.reject() is
+ // called.
+
+ for (let { handler, extension } of this.listeners) {
+ let result = await handler(
+ composeWindow,
+ await getComposeDetails(composeWindow, extension)
+ );
+ if (!result) {
+ continue;
+ }
+ if (result.cancel) {
+ composeWindow.ToggleWindowLock(false);
+ sendPromise.reject();
+ return;
+ }
+ if (result.details) {
+ await setComposeDetails(composeWindow, result.details, extension);
+ }
+ }
+
+ // Load the new details into gMsgCompose.compFields for sending.
+ composeWindow.GetComposeDetails();
+
+ composeWindow.ToggleWindowLock(false);
+ sendPromise.resolve();
+ },
+};
+
+var composeAttachmentTracker = {
+ _nextId: 1,
+ _attachments: new Map(),
+ _attachmentIds: new Map(),
+
+ getId(attachment, window) {
+ if (this._attachmentIds.has(attachment)) {
+ return this._attachmentIds.get(attachment).id;
+ }
+ let id = this._nextId++;
+ this._attachments.set(id, { attachment, window });
+ this._attachmentIds.set(attachment, { id, window });
+ return id;
+ },
+
+ getAttachment(id) {
+ return this._attachments.get(id);
+ },
+
+ hasAttachment(id) {
+ return this._attachments.has(id);
+ },
+
+ forgetAttachment(attachment) {
+ // This is called on all attachments when the window closes, whether the
+ // attachments have been assigned IDs or not.
+ let id = this._attachmentIds.get(attachment)?.id;
+ if (id) {
+ this._attachmentIds.delete(attachment);
+ this._attachments.delete(id);
+ }
+ },
+
+ forgetAttachments(window) {
+ if (window.location.href == COMPOSE_WINDOW_URI) {
+ let bucket = window.document.getElementById("attachmentBucket");
+ for (let item of bucket.itemChildren) {
+ this.forgetAttachment(item.attachment);
+ }
+ }
+ },
+
+ convert(attachment, window) {
+ return {
+ id: this.getId(attachment, window),
+ name: attachment.name,
+ size: attachment.size,
+ };
+ },
+
+ getFile(attachment) {
+ if (!attachment) {
+ return null;
+ }
+ let uri = Services.io.newURI(attachment.url).QueryInterface(Ci.nsIFileURL);
+ // Enforce the actual filename used in the composer, do not leak internal or
+ // temporary filenames.
+ return File.createFromNsIFile(uri.file, { name: attachment.name });
+ },
+};
+
+windowTracker.addCloseListener(
+ composeAttachmentTracker.forgetAttachments.bind(composeAttachmentTracker)
+);
+
+var composeWindowTracker = new Set();
+windowTracker.addCloseListener(window => composeWindowTracker.delete(window));
+
+this.compose = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onBeforeSend({ context, fire }) {
+ const { extension } = this;
+ const { tabManager, windowManager } = extension;
+ let listener = {
+ async handler(window, details) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ let win = windowManager.wrapWindow(window);
+ return fire.async(
+ tabManager.convert(win.activeTab.nativeTab),
+ details
+ );
+ },
+ extension,
+ };
+
+ beforeSendEventTracker.addListener(listener);
+ return {
+ unregister: () => {
+ beforeSendEventTracker.removeListener(listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onAfterSend({ context, fire }) {
+ const { extension } = this;
+ const { tabManager, windowManager } = extension;
+ let listener = {
+ async onSuccess(window, mode, messages, headerMessageId) {
+ let win = windowManager.wrapWindow(window);
+ let tab = tabManager.convert(win.activeTab.nativeTab);
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ let sendInfo = { mode, messages };
+ if (mode == "sendNow") {
+ sendInfo.headerMessageId = headerMessageId;
+ }
+ return fire.async(tab, sendInfo);
+ },
+ async onFailure(window, mode, exception) {
+ let win = windowManager.wrapWindow(window);
+ let tab = tabManager.convert(win.activeTab.nativeTab);
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ return fire.async(tab, {
+ mode,
+ messages: [],
+ error: exception.message,
+ });
+ },
+ modes: ["sendNow", "sendLater"],
+ extension,
+ };
+ afterSaveSendEventTracker.addListener(listener);
+ return {
+ unregister: () => {
+ afterSaveSendEventTracker.removeListener(listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onAfterSave({ context, fire }) {
+ const { extension } = this;
+ const { tabManager, windowManager } = extension;
+ let listener = {
+ async onSuccess(window, mode, messages, headerMessageId) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ let win = windowManager.wrapWindow(window);
+ let saveInfo = { mode, messages };
+ return fire.async(
+ tabManager.convert(win.activeTab.nativeTab),
+ saveInfo
+ );
+ },
+ async onFailure(window, mode, exception) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ let win = windowManager.wrapWindow(window);
+ return fire.async(tabManager.convert(win.activeTab.nativeTab), {
+ mode,
+ messages: [],
+ error: exception.message,
+ });
+ },
+ modes: ["draft", "template"],
+ extension,
+ };
+ afterSaveSendEventTracker.addListener(listener);
+ return {
+ unregister: () => {
+ afterSaveSendEventTracker.removeListener(listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onAttachmentAdded({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(event) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ for (let attachment of event.detail) {
+ attachment = composeAttachmentTracker.convert(
+ attachment,
+ event.target.ownerGlobal
+ );
+ fire.async(tabManager.convert(event.target.ownerGlobal), attachment);
+ }
+ }
+ windowTracker.addListener("attachments-added", listener);
+ return {
+ unregister: () => {
+ windowTracker.removeListener("attachments-added", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onAttachmentRemoved({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(event) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ for (let attachment of event.detail) {
+ let attachmentId = composeAttachmentTracker.getId(
+ attachment,
+ event.target.ownerGlobal
+ );
+ fire.async(
+ tabManager.convert(event.target.ownerGlobal),
+ attachmentId
+ );
+ composeAttachmentTracker.forgetAttachment(attachment);
+ }
+ }
+ windowTracker.addListener("attachments-removed", listener);
+ return {
+ unregister: () => {
+ windowTracker.removeListener("attachments-removed", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onIdentityChanged({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(event) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(
+ tabManager.convert(event.target.ownerGlobal),
+ event.target.getCurrentIdentityKey()
+ );
+ }
+ windowTracker.addListener("compose-from-changed", listener);
+ return {
+ unregister: () => {
+ windowTracker.removeListener("compose-from-changed", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onComposeStateChanged({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(event) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(
+ tabManager.convert(event.target.ownerGlobal),
+ composeStates.convert(event.detail)
+ );
+ }
+ windowTracker.addListener("compose-state-changed", listener);
+ return {
+ unregister: () => {
+ windowTracker.removeListener("compose-state-changed", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onActiveDictionariesChanged({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(event) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ let activeDictionaries = event.detail.split(",");
+ fire.async(
+ tabManager.convert(event.target.ownerGlobal),
+ Cc["@mozilla.org/spellchecker/engine;1"]
+ .getService(Ci.mozISpellCheckingEngine)
+ .getDictionaryList()
+ .reduce((list, dict) => {
+ list[dict] = activeDictionaries.includes(dict);
+ return list;
+ }, {})
+ );
+ }
+ windowTracker.addListener("active-dictionaries-changed", listener);
+ return {
+ unregister: () => {
+ windowTracker.removeListener("active-dictionaries-changed", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ /**
+ * Guard to make sure the API waits until the compose tab has been fully loaded,
+ * to cope with tabs.onCreated returning tabs very early.
+ *
+ * @param {integer} tabId
+ * @returns {Tab} a fully loaded messageCompose tab
+ */
+ async function getComposeTab(tabId) {
+ let tab = tabManager.get(tabId);
+ if (tab.type != "messageCompose") {
+ throw new ExtensionError(`Invalid compose tab: ${tabId}`);
+ }
+ await composeWindowIsReady(tab.nativeTab);
+ return tab;
+ }
+
+ let { extension } = context;
+ let { tabManager } = extension;
+
+ return {
+ compose: {
+ onBeforeSend: new EventManager({
+ context,
+ module: "compose",
+ event: "onBeforeSend",
+ inputHandling: true,
+ extensionApi: this,
+ }).api(),
+ onAfterSend: new EventManager({
+ context,
+ module: "compose",
+ event: "onAfterSend",
+ inputHandling: true,
+ extensionApi: this,
+ }).api(),
+ onAfterSave: new EventManager({
+ context,
+ module: "compose",
+ event: "onAfterSave",
+ inputHandling: true,
+ extensionApi: this,
+ }).api(),
+ onAttachmentAdded: new ExtensionCommon.EventManager({
+ context,
+ module: "compose",
+ event: "onAttachmentAdded",
+ extensionApi: this,
+ }).api(),
+ onAttachmentRemoved: new ExtensionCommon.EventManager({
+ context,
+ module: "compose",
+ event: "onAttachmentRemoved",
+ extensionApi: this,
+ }).api(),
+ onIdentityChanged: new ExtensionCommon.EventManager({
+ context,
+ module: "compose",
+ event: "onIdentityChanged",
+ extensionApi: this,
+ }).api(),
+ onComposeStateChanged: new ExtensionCommon.EventManager({
+ context,
+ module: "compose",
+ event: "onComposeStateChanged",
+ extensionApi: this,
+ }).api(),
+ onActiveDictionariesChanged: new ExtensionCommon.EventManager({
+ context,
+ module: "compose",
+ event: "onActiveDictionariesChanged",
+ extensionApi: this,
+ }).api(),
+ async beginNew(messageId, details) {
+ let type = Ci.nsIMsgCompType.New;
+ if (messageId) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ type =
+ msgHdr.flags & Ci.nsMsgMessageFlags.Template
+ ? Ci.nsIMsgCompType.Template
+ : Ci.nsIMsgCompType.EditAsNew;
+ }
+ let composeWindow = await openComposeWindow(
+ messageId,
+ type,
+ details,
+ extension
+ );
+ return tabManager.convert(composeWindow);
+ },
+ async beginReply(messageId, replyType, details) {
+ let type = Ci.nsIMsgCompType.Reply;
+ if (replyType == "replyToList") {
+ type = Ci.nsIMsgCompType.ReplyToList;
+ } else if (replyType == "replyToAll") {
+ type = Ci.nsIMsgCompType.ReplyAll;
+ }
+ let composeWindow = await openComposeWindow(
+ messageId,
+ type,
+ details,
+ extension
+ );
+ return tabManager.convert(composeWindow);
+ },
+ async beginForward(messageId, forwardType, details) {
+ let type = Ci.nsIMsgCompType.ForwardInline;
+ if (forwardType == "forwardAsAttachment") {
+ type = Ci.nsIMsgCompType.ForwardAsAttachment;
+ } else if (
+ forwardType === null &&
+ Services.prefs.getIntPref("mail.forward_message_mode") == 0
+ ) {
+ type = Ci.nsIMsgCompType.ForwardAsAttachment;
+ }
+ let composeWindow = await openComposeWindow(
+ messageId,
+ type,
+ details,
+ extension
+ );
+ return tabManager.convert(composeWindow);
+ },
+ async saveMessage(tabId, options) {
+ let tab = await getComposeTab(tabId);
+ let saveMode = options?.mode || "draft";
+
+ try {
+ return await goDoCommand(
+ tab.nativeTab,
+ context.extension,
+ saveMode
+ );
+ } catch (ex) {
+ throw new ExtensionError(
+ `compose.saveMessage failed: ${ex.message}`
+ );
+ }
+ },
+ async sendMessage(tabId, options) {
+ let tab = await getComposeTab(tabId);
+ let sendMode = options?.mode;
+ if (!["sendLater", "sendNow"].includes(sendMode)) {
+ sendMode = Services.io.offline ? "sendLater" : "sendNow";
+ }
+
+ try {
+ return await goDoCommand(
+ tab.nativeTab,
+ context.extension,
+ sendMode
+ );
+ } catch (ex) {
+ throw new ExtensionError(
+ `compose.sendMessage failed: ${ex.message}`
+ );
+ }
+ },
+ async getComposeState(tabId) {
+ let tab = await getComposeTab(tabId);
+ return composeStates.getStates(tab);
+ },
+ async getComposeDetails(tabId) {
+ let tab = await getComposeTab(tabId);
+ return getComposeDetails(tab.nativeTab, extension);
+ },
+ async setComposeDetails(tabId, details) {
+ let tab = await getComposeTab(tabId);
+ return setComposeDetails(tab.nativeTab, details, extension);
+ },
+ async getActiveDictionaries(tabId) {
+ let tab = await getComposeTab(tabId);
+ let dictionaries = tab.nativeTab.gActiveDictionaries;
+
+ // Return the list of installed dictionaries, setting those who are
+ // enabled to true.
+ return Cc["@mozilla.org/spellchecker/engine;1"]
+ .getService(Ci.mozISpellCheckingEngine)
+ .getDictionaryList()
+ .reduce((list, dict) => {
+ list[dict] = dictionaries.has(dict);
+ return list;
+ }, {});
+ },
+ async setActiveDictionaries(tabId, activeDictionaries) {
+ let tab = await getComposeTab(tabId);
+ let installedDictionaries = Cc["@mozilla.org/spellchecker/engine;1"]
+ .getService(Ci.mozISpellCheckingEngine)
+ .getDictionaryList();
+
+ for (let dict of activeDictionaries) {
+ if (!installedDictionaries.includes(dict)) {
+ throw new ExtensionError(`Dictionary not found: ${dict}`);
+ }
+ }
+
+ await tab.nativeTab.ComposeChangeLanguage(activeDictionaries);
+ },
+ async listAttachments(tabId) {
+ let tab = await getComposeTab(tabId);
+
+ let bucket =
+ tab.nativeTab.document.getElementById("attachmentBucket");
+ let attachments = [];
+ for (let item of bucket.itemChildren) {
+ attachments.push(
+ composeAttachmentTracker.convert(item.attachment, tab.nativeTab)
+ );
+ }
+ return attachments;
+ },
+ async getAttachmentFile(attachmentId) {
+ if (!composeAttachmentTracker.hasAttachment(attachmentId)) {
+ throw new ExtensionError(`Invalid attachment: ${attachmentId}`);
+ }
+ let { attachment } =
+ composeAttachmentTracker.getAttachment(attachmentId);
+ return composeAttachmentTracker.getFile(attachment);
+ },
+ async addAttachment(tabId, data) {
+ let tab = await getComposeTab(tabId);
+ let attachmentData = await createAttachment(data);
+ await AddAttachmentsToWindow(tab.nativeTab, [attachmentData]);
+ return composeAttachmentTracker.convert(
+ attachmentData.attachment,
+ tab.nativeTab
+ );
+ },
+ async updateAttachment(tabId, attachmentId, data) {
+ let tab = await getComposeTab(tabId);
+ if (!composeAttachmentTracker.hasAttachment(attachmentId)) {
+ throw new ExtensionError(`Invalid attachment: ${attachmentId}`);
+ }
+ let { attachment, window } =
+ composeAttachmentTracker.getAttachment(attachmentId);
+ if (window != tab.nativeTab) {
+ throw new ExtensionError(
+ `Attachment ${attachmentId} is not associated with tab ${tabId}`
+ );
+ }
+
+ let attachmentItem =
+ window.gAttachmentBucket.findItemForAttachment(attachment);
+ if (!attachmentItem) {
+ throw new ExtensionError(`Unexpected invalid attachment item`);
+ }
+
+ if (!data.file && !data.name) {
+ throw new ExtensionError(
+ `Either data.file or data.name property must be specified`
+ );
+ }
+
+ let realFile = data.file ? await getRealFileForFile(data.file) : null;
+ try {
+ await window.UpdateAttachment(attachmentItem, {
+ file: realFile,
+ name: data.name,
+ relatedCloudFileUpload: attachmentItem.cloudFileUpload,
+ });
+ } catch (ex) {
+ throw new ExtensionError(ex.message);
+ }
+
+ return composeAttachmentTracker.convert(attachmentItem.attachment);
+ },
+ async removeAttachment(tabId, attachmentId) {
+ let tab = await getComposeTab(tabId);
+ if (!composeAttachmentTracker.hasAttachment(attachmentId)) {
+ throw new ExtensionError(`Invalid attachment: ${attachmentId}`);
+ }
+ let { attachment, window } =
+ composeAttachmentTracker.getAttachment(attachmentId);
+ if (window != tab.nativeTab) {
+ throw new ExtensionError(
+ `Attachment ${attachmentId} is not associated with tab ${tabId}`
+ );
+ }
+
+ let item = window.gAttachmentBucket.findItemForAttachment(attachment);
+ await window.RemoveAttachments([item]);
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-composeAction.js b/comm/mail/components/extensions/parent/ext-composeAction.js
new file mode 100644
index 0000000000..fb2a462d33
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-composeAction.js
@@ -0,0 +1,154 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ToolbarButtonAPI",
+ "resource:///modules/ExtensionToolbarButtons.jsm"
+);
+
+const composeActionMap = new WeakMap();
+
+var { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+var { makeWidgetId } = ExtensionCommon;
+
+this.composeAction = class extends ToolbarButtonAPI {
+ static for(extension) {
+ return composeActionMap.get(extension);
+ }
+
+ async onManifestEntry(entryName) {
+ await super.onManifestEntry(entryName);
+ composeActionMap.set(this.extension, this);
+ }
+
+ close() {
+ super.close();
+ composeActionMap.delete(this.extension);
+ }
+
+ constructor(extension) {
+ super(extension, global);
+ this.manifest_name = "compose_action";
+ this.manifestName = "composeAction";
+ this.manifest = extension.manifest[this.manifest_name];
+ this.moduleName = this.manifestName;
+
+ this.windowURLs = [
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml",
+ ];
+ let isFormatToolbar =
+ extension.manifest.compose_action.default_area == "formattoolbar";
+ this.toolboxId = isFormatToolbar ? "FormatToolbox" : "compose-toolbox";
+ this.toolbarId = isFormatToolbar ? "FormatToolbar" : "composeToolbar2";
+ }
+
+ static onUninstall(extensionId) {
+ let widgetId = makeWidgetId(extensionId);
+ let id = `${widgetId}-composeAction-toolbarbutton`;
+ let windowURL =
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml";
+
+ // Check all possible toolbars and remove the toolbarbutton if found.
+ // Sadly we have to hardcode these values here, as the add-on is already
+ // shutdown when onUninstall is called.
+ let toolbars = ["composeToolbar2", "FormatToolbar"];
+ for (let toolbar of toolbars) {
+ for (let setName of ["currentset", "extensionset"]) {
+ let set = Services.xulStore
+ .getValue(windowURL, toolbar, setName)
+ .split(",");
+ let newSet = set.filter(e => e != id);
+ if (newSet.length < set.length) {
+ Services.xulStore.setValue(
+ windowURL,
+ toolbar,
+ setName,
+ newSet.join(",")
+ );
+ }
+ }
+ }
+ }
+
+ handleEvent(event) {
+ super.handleEvent(event);
+ let window = event.target.ownerGlobal;
+
+ switch (event.type) {
+ case "popupshowing":
+ const menu = event.target;
+ if (menu.tagName != "menupopup") {
+ return;
+ }
+
+ const trigger = menu.triggerNode;
+ const node = window.document.getElementById(this.id);
+ const contexts = [
+ "format-toolbar-context-menu",
+ "toolbar-context-menu",
+ "customizationPanelItemContextMenu",
+ ];
+ if (contexts.includes(menu.id) && node && node.contains(trigger)) {
+ global.actionContextMenu({
+ tab: window,
+ pageUrl: window.browser.currentURI.spec,
+ extension: this.extension,
+ onComposeAction: true,
+ menu,
+ });
+ }
+
+ if (
+ menu.dataset.actionMenu == "composeAction" &&
+ this.extension.id == menu.dataset.extensionId
+ ) {
+ global.actionContextMenu({
+ tab: window,
+ pageUrl: window.browser.currentURI.spec,
+ extension: this.extension,
+ inComposeActionMenu: true,
+ menu,
+ });
+ }
+ break;
+ }
+ }
+
+ makeButton(window) {
+ let button = super.makeButton(window);
+ if (this.toolbarId == "FormatToolbar") {
+ button.classList.add("formatting-button");
+ // The format toolbar has no associated context menu. Add one directly to
+ // this button.
+ button.setAttribute("context", "format-toolbar-context-menu");
+ }
+ return button;
+ }
+
+ /**
+ * Returns an element in the toolbar, which is to be used as default insertion
+ * point for new toolbar buttons in non-customizable toolbars.
+ *
+ * May return null to append new buttons to the end of the toolbar.
+ *
+ * @param {DOMElement} toolbar - a toolbar node
+ * @returns {DOMElement} a node which is to be used as insertion point, or null
+ */
+ getNonCustomizableToolbarInsertionPoint(toolbar) {
+ let before = toolbar.lastElementChild;
+ while (before.localName == "spacer") {
+ before = before.previousElementSibling;
+ }
+ return before.nextElementSibling;
+ }
+};
+
+global.composeActionFor = this.composeAction.for;
diff --git a/comm/mail/components/extensions/parent/ext-extensionScripts.js b/comm/mail/components/extensions/parent/ext-extensionScripts.js
new file mode 100644
index 0000000000..ef5da07586
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-extensionScripts.js
@@ -0,0 +1,185 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionSupport } = ChromeUtils.import(
+ "resource:///modules/ExtensionSupport.jsm"
+);
+var { ExtensionUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionUtils.sys.mjs"
+);
+
+var { getUniqueId } = ExtensionUtils;
+
+let scripts = new Set();
+
+ExtensionSupport.registerWindowListener("ext-composeScripts", {
+ chromeURLs: [
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml",
+ ],
+ onLoadWindow: async win => {
+ await new Promise(resolve =>
+ win.addEventListener("compose-editor-ready", resolve, { once: true })
+ );
+ for (let script of scripts) {
+ if (script.type == "compose") {
+ script.executeInWindow(
+ win,
+ script.extension.tabManager.getWrapper(win)
+ );
+ }
+ }
+ },
+});
+
+ExtensionSupport.registerWindowListener("ext-messageDisplayScripts", {
+ chromeURLs: [
+ "chrome://messenger/content/messageWindow.xhtml",
+ "chrome://messenger/content/messenger.xhtml",
+ ],
+ onLoadWindow(win) {
+ win.addEventListener("MsgLoaded", event => {
+ // `event.target` is an about:message window.
+ let nativeTab = event.target.tabOrWindow;
+ for (let script of scripts) {
+ if (script.type == "messageDisplay") {
+ script.executeInWindow(
+ win,
+ script.extension.tabManager.wrapTab(nativeTab)
+ );
+ }
+ }
+ });
+ },
+});
+
+/**
+ * Represents (in the main browser process) a script registered
+ * programmatically (instead of being included in the addon manifest).
+ *
+ * @param {ProxyContextParent} context
+ * The parent proxy context related to the extension context which
+ * has registered the script.
+ * @param {RegisteredScriptOptions} details
+ * The options object related to the registered script
+ * (which has the properties described in the extensionScripts.json
+ * JSON API schema file).
+ */
+class ExtensionScriptParent {
+ constructor(type, context, details) {
+ this.type = type;
+ this.context = context;
+ this.extension = context.extension;
+ this.scriptId = getUniqueId();
+
+ this.options = this._convertOptions(details);
+ context.callOnClose(this);
+
+ scripts.add(this);
+ }
+
+ close() {
+ this.destroy();
+ }
+
+ destroy() {
+ if (this.destroyed) {
+ throw new ExtensionError("Unable to destroy ExtensionScriptParent twice");
+ }
+
+ scripts.delete(this);
+
+ this.destroyed = true;
+ this.context.forgetOnClose(this);
+ this.context = null;
+ this.options = null;
+ }
+
+ _convertOptions(details) {
+ const options = {
+ js: [],
+ css: [],
+ };
+
+ if (details.js && details.js.length) {
+ options.js = details.js.map(data => {
+ return {
+ code: data.code || null,
+ file: data.file || null,
+ };
+ });
+ }
+
+ if (details.css && details.css.length) {
+ options.css = details.css.map(data => {
+ return {
+ code: data.code || null,
+ file: data.file || null,
+ };
+ });
+ }
+
+ return options;
+ }
+
+ async executeInWindow(window, tab) {
+ for (let css of this.options.css) {
+ await tab.insertCSS(this.context, { ...css, frameId: null });
+ }
+ for (let js of this.options.js) {
+ await tab.executeScript(this.context, { ...js, frameId: null });
+ }
+ window.dispatchEvent(new window.CustomEvent("extension-scripts-added"));
+ }
+}
+
+this.extensionScripts = class extends ExtensionAPI {
+ getAPI(context) {
+ // Map of the script registered from the extension context.
+ //
+ // Map<scriptId -> ExtensionScriptParent>
+ const parentScriptsMap = new Map();
+
+ // Unregister all the scriptId related to a context when it is closed.
+ context.callOnClose({
+ close() {
+ for (let script of parentScriptsMap.values()) {
+ script.destroy();
+ }
+ parentScriptsMap.clear();
+ },
+ });
+
+ return {
+ extensionScripts: {
+ async register(type, details) {
+ const script = new ExtensionScriptParent(type, context, details);
+ const { scriptId } = script;
+
+ parentScriptsMap.set(scriptId, script);
+ return scriptId;
+ },
+
+ // This method is not available to the extension code, the extension code
+ // doesn't have access to the internally used scriptId, on the contrary
+ // the extension code will call script.unregister on the script API object
+ // that is resolved from the register API method returned promise.
+ async unregister(scriptId) {
+ const script = parentScriptsMap.get(scriptId);
+ if (!script) {
+ console.error(new ExtensionError(`No such script ID: ${scriptId}`));
+
+ return;
+ }
+
+ parentScriptsMap.delete(scriptId);
+ script.destroy();
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-folders.js b/comm/mail/components/extensions/parent/ext-folders.js
new file mode 100644
index 0000000000..63704b9dd7
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-folders.js
@@ -0,0 +1,675 @@
+/* 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/. */
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailServices",
+ "resource:///modules/MailServices.jsm"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+});
+
+/**
+ * Tracks folder events.
+ *
+ * @implements {nsIMsgFolderListener}
+ */
+var folderTracker = new (class extends EventEmitter {
+ constructor() {
+ super();
+ this.listenerCount = 0;
+ this.pendingInfoNotifications = new ExtensionUtils.DefaultMap(
+ () => new Map()
+ );
+ this.deferredInfoNotifications = new ExtensionUtils.DefaultMap(
+ folder =>
+ new DeferredTask(
+ () => this.emitPendingInfoNotification(folder),
+ NOTIFICATION_COLLAPSE_TIME
+ )
+ );
+ }
+
+ on(...args) {
+ super.on(...args);
+ this.incrementListeners();
+ }
+
+ off(...args) {
+ super.off(...args);
+ this.decrementListeners();
+ }
+
+ incrementListeners() {
+ this.listenerCount++;
+ if (this.listenerCount == 1) {
+ // nsIMsgFolderListener
+ const flags =
+ MailServices.mfn.folderAdded |
+ MailServices.mfn.folderDeleted |
+ MailServices.mfn.folderMoveCopyCompleted |
+ MailServices.mfn.folderRenamed;
+ MailServices.mfn.addListener(this, flags);
+ // nsIFolderListener
+ MailServices.mailSession.AddFolderListener(
+ this,
+ Ci.nsIFolderListener.intPropertyChanged
+ );
+ }
+ }
+ decrementListeners() {
+ this.listenerCount--;
+ if (this.listenerCount == 0) {
+ MailServices.mfn.removeListener(this);
+ MailServices.mailSession.RemoveFolderListener(this);
+ }
+ }
+
+ // nsIFolderListener
+
+ onFolderIntPropertyChanged(item, property, oldValue, newValue) {
+ if (!(item instanceof Ci.nsIMsgFolder)) {
+ return;
+ }
+
+ switch (property) {
+ case "FolderFlag":
+ if (
+ (oldValue & Ci.nsMsgFolderFlags.Favorite) !=
+ (newValue & Ci.nsMsgFolderFlags.Favorite)
+ ) {
+ this.addPendingInfoNotification(
+ item,
+ "favorite",
+ !!(newValue & Ci.nsMsgFolderFlags.Favorite)
+ );
+ }
+ break;
+ case "TotalMessages":
+ this.addPendingInfoNotification(item, "totalMessageCount", newValue);
+ break;
+ case "TotalUnreadMessages":
+ this.addPendingInfoNotification(item, "unreadMessageCount", newValue);
+ break;
+ }
+ }
+
+ addPendingInfoNotification(folder, key, value) {
+ // If there is already a notification entry, decide if it must be emitted,
+ // or if it can be collapsed: Message count changes can be collapsed.
+ // This also collapses multiple different notifications types into a
+ // single event.
+ if (
+ ["favorite"].includes(key) &&
+ this.deferredInfoNotifications.has(folder) &&
+ this.pendingInfoNotifications.get(folder).has(key)
+ ) {
+ this.deferredInfoNotifications.get(folder).disarm();
+ this.emitPendingInfoNotification(folder);
+ }
+
+ this.pendingInfoNotifications.get(folder).set(key, value);
+ this.deferredInfoNotifications.get(folder).disarm();
+ this.deferredInfoNotifications.get(folder).arm();
+ }
+
+ emitPendingInfoNotification(folder) {
+ let folderInfo = this.pendingInfoNotifications.get(folder);
+ if (folderInfo.size > 0) {
+ this.emit(
+ "folder-info-changed",
+ convertFolder(folder),
+ Object.fromEntries(folderInfo)
+ );
+ this.pendingInfoNotifications.delete(folder);
+ }
+ }
+
+ // nsIMsgFolderListener
+
+ folderAdded(childFolder) {
+ this.emit("folder-created", convertFolder(childFolder));
+ }
+ folderDeleted(oldFolder) {
+ // Deleting an account, will trigger delete notifications for its folders,
+ // but the account lookup fails, so skip them.
+ let server = oldFolder.server;
+ let account = MailServices.accounts.FindAccountForServer(server);
+ if (account) {
+ this.emit("folder-deleted", convertFolder(oldFolder, account.key));
+ }
+ }
+ folderMoveCopyCompleted(move, srcFolder, targetFolder) {
+ // targetFolder is not the copied/moved folder, but its parent. Find the
+ // actual folder by its name (which is unique).
+ let dstFolder = null;
+ if (targetFolder && targetFolder.hasSubFolders) {
+ dstFolder = targetFolder.subFolders.find(
+ f => f.prettyName == srcFolder.prettyName
+ );
+ }
+
+ if (move) {
+ this.emit(
+ "folder-moved",
+ convertFolder(srcFolder),
+ convertFolder(dstFolder)
+ );
+ } else {
+ this.emit(
+ "folder-copied",
+ convertFolder(srcFolder),
+ convertFolder(dstFolder)
+ );
+ }
+ }
+ folderRenamed(oldFolder, newFolder) {
+ this.emit(
+ "folder-renamed",
+ convertFolder(oldFolder),
+ convertFolder(newFolder)
+ );
+ }
+})();
+
+/**
+ * Accepts a MailFolder or a MailAccount and returns the actual folder and its
+ * accountId. Throws if the requested folder does not exist.
+ */
+function getFolder({ accountId, path, id }) {
+ if (id && !path && !accountId) {
+ accountId = id;
+ path = "/";
+ }
+
+ let uri = folderPathToURI(accountId, path);
+ let folder = MailServices.folderLookup.getFolderForURL(uri);
+ if (!folder) {
+ throw new ExtensionError(`Folder not found: ${path}`);
+ }
+ return { folder, accountId };
+}
+
+/**
+ * Copy or Move a folder.
+ */
+async function doMoveCopyOperation(source, destination, isMove) {
+ // The schema file allows destination to be either a MailFolder or a
+ // MailAccount.
+ let srcFolder = getFolder(source);
+ let dstFolder = getFolder(destination);
+
+ if (
+ srcFolder.folder.server.type == "nntp" ||
+ dstFolder.folder.server.type == "nntp"
+ ) {
+ throw new ExtensionError(
+ `folders.${isMove ? "move" : "copy"}() is not supported in news accounts`
+ );
+ }
+
+ if (
+ dstFolder.folder.hasSubFolders &&
+ dstFolder.folder.subFolders.find(
+ f => f.prettyName == srcFolder.folder.prettyName
+ )
+ ) {
+ throw new ExtensionError(
+ `folders.${isMove ? "move" : "copy"}() failed, because ${
+ srcFolder.folder.prettyName
+ } already exists in ${folderURIToPath(
+ dstFolder.accountId,
+ dstFolder.folder.URI
+ )}`
+ );
+ }
+
+ let rv = await new Promise(resolve => {
+ let _destination = null;
+ const listener = {
+ folderMoveCopyCompleted(_isMove, _srcFolder, _dstFolder) {
+ if (
+ _destination != null ||
+ _isMove != isMove ||
+ _srcFolder.URI != srcFolder.folder.URI ||
+ _dstFolder.URI != dstFolder.folder.URI
+ ) {
+ return;
+ }
+
+ // The targetFolder is not the copied/moved folder, but its parent.
+ // Find the actual folder by its name (which is unique).
+ if (_dstFolder && _dstFolder.hasSubFolders) {
+ _destination = _dstFolder.subFolders.find(
+ f => f.prettyName == _srcFolder.prettyName
+ );
+ }
+ },
+ };
+ MailServices.mfn.addListener(
+ listener,
+ MailServices.mfn.folderMoveCopyCompleted
+ );
+ MailServices.copy.copyFolder(
+ srcFolder.folder,
+ dstFolder.folder,
+ isMove,
+ {
+ OnStartCopy() {},
+ OnProgress() {},
+ SetMessageKey() {},
+ GetMessageId() {},
+ OnStopCopy(status) {
+ MailServices.mfn.removeListener(listener);
+ resolve({
+ status,
+ folder: _destination,
+ });
+ },
+ },
+ null
+ );
+ });
+
+ if (!Components.isSuccessCode(rv.status)) {
+ throw new ExtensionError(
+ `folders.${isMove ? "move" : "copy"}() failed for unknown reasons`
+ );
+ }
+
+ return convertFolder(rv.folder, dstFolder.accountId);
+}
+
+/**
+ * Wait for a folder operation.
+ */
+function waitForOperation(flags, uri) {
+ return new Promise(resolve => {
+ MailServices.mfn.addListener(
+ {
+ folderAdded(childFolder) {
+ if (childFolder.parent.URI != uri) {
+ return;
+ }
+
+ MailServices.mfn.removeListener(this);
+ resolve(childFolder);
+ },
+ folderDeleted(oldFolder) {
+ if (oldFolder.URI != uri) {
+ return;
+ }
+
+ MailServices.mfn.removeListener(this);
+ resolve();
+ },
+ folderMoveCopyCompleted(move, srcFolder, destFolder) {
+ if (srcFolder.URI != uri) {
+ return;
+ }
+
+ MailServices.mfn.removeListener(this);
+ resolve(destFolder);
+ },
+ folderRenamed(oldFolder, newFolder) {
+ if (oldFolder.URI != uri) {
+ return;
+ }
+
+ MailServices.mfn.removeListener(this);
+ resolve(newFolder);
+ },
+ },
+ flags
+ );
+ });
+}
+
+this.folders = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onCreated({ context, fire }) {
+ async function listener(event, createdMailFolder) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(createdMailFolder);
+ }
+ folderTracker.on("folder-created", listener);
+ return {
+ unregister: () => {
+ folderTracker.off("folder-created", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onRenamed({ context, fire }) {
+ async function listener(event, originalMailFolder, renamedMailFolder) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(originalMailFolder, renamedMailFolder);
+ }
+ folderTracker.on("folder-renamed", listener);
+ return {
+ unregister: () => {
+ folderTracker.off("folder-renamed", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onMoved({ context, fire }) {
+ async function listener(event, srcMailFolder, dstMailFolder) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(srcMailFolder, dstMailFolder);
+ }
+ folderTracker.on("folder-moved", listener);
+ return {
+ unregister: () => {
+ folderTracker.off("folder-moved", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onCopied({ context, fire }) {
+ async function listener(event, srcMailFolder, dstMailFolder) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(srcMailFolder, dstMailFolder);
+ }
+ folderTracker.on("folder-copied", listener);
+ return {
+ unregister: () => {
+ folderTracker.off("folder-copied", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onDeleted({ context, fire }) {
+ async function listener(event, deletedMailFolder) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(deletedMailFolder);
+ }
+ folderTracker.on("folder-deleted", listener);
+ return {
+ unregister: () => {
+ folderTracker.off("folder-deleted", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onFolderInfoChanged({ context, fire }) {
+ async function listener(event, changedMailFolder, mailFolderInfo) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(changedMailFolder, mailFolderInfo);
+ }
+ folderTracker.on("folder-info-changed", listener);
+ return {
+ unregister: () => {
+ folderTracker.off("folder-info-changed", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ return {
+ folders: {
+ onCreated: new EventManager({
+ context,
+ module: "folders",
+ event: "onCreated",
+ extensionApi: this,
+ }).api(),
+ onRenamed: new EventManager({
+ context,
+ module: "folders",
+ event: "onRenamed",
+ extensionApi: this,
+ }).api(),
+ onMoved: new EventManager({
+ context,
+ module: "folders",
+ event: "onMoved",
+ extensionApi: this,
+ }).api(),
+ onCopied: new EventManager({
+ context,
+ module: "folders",
+ event: "onCopied",
+ extensionApi: this,
+ }).api(),
+ onDeleted: new EventManager({
+ context,
+ module: "folders",
+ event: "onDeleted",
+ extensionApi: this,
+ }).api(),
+ onFolderInfoChanged: new EventManager({
+ context,
+ module: "folders",
+ event: "onFolderInfoChanged",
+ extensionApi: this,
+ }).api(),
+ async create(parent, childName) {
+ // The schema file allows parent to be either a MailFolder or a
+ // MailAccount.
+ let { folder: parentFolder, accountId } = getFolder(parent);
+
+ if (
+ parentFolder.hasSubFolders &&
+ parentFolder.subFolders.find(f => f.prettyName == childName)
+ ) {
+ throw new ExtensionError(
+ `folders.create() failed, because ${childName} already exists in ${folderURIToPath(
+ accountId,
+ parentFolder.URI
+ )}`
+ );
+ }
+
+ let childFolderPromise = waitForOperation(
+ MailServices.mfn.folderAdded,
+ parentFolder.URI
+ );
+ parentFolder.createSubfolder(childName, null);
+
+ let childFolder = await childFolderPromise;
+ return convertFolder(childFolder, accountId);
+ },
+ async rename({ accountId, path }, newName) {
+ let { folder } = getFolder({ accountId, path });
+
+ if (!folder.parent) {
+ throw new ExtensionError(
+ `folders.rename() failed, because it cannot rename the root of the account`
+ );
+ }
+ if (folder.server.type == "nntp") {
+ throw new ExtensionError(
+ `folders.rename() is not supported in news accounts`
+ );
+ }
+
+ if (folder.parent.subFolders.find(f => f.prettyName == newName)) {
+ throw new ExtensionError(
+ `folders.rename() failed, because ${newName} already exists in ${folderURIToPath(
+ accountId,
+ folder.parent.URI
+ )}`
+ );
+ }
+
+ let newFolderPromise = waitForOperation(
+ MailServices.mfn.folderRenamed,
+ folder.URI
+ );
+ folder.rename(newName, null);
+
+ let newFolder = await newFolderPromise;
+ return convertFolder(newFolder, accountId);
+ },
+ async move(source, destination) {
+ return doMoveCopyOperation(source, destination, true /* isMove */);
+ },
+ async copy(source, destination) {
+ return doMoveCopyOperation(source, destination, false /* isMove */);
+ },
+ async delete({ accountId, path }) {
+ if (
+ !context.extension.hasPermission("accountsFolders") ||
+ !context.extension.hasPermission("messagesDelete")
+ ) {
+ throw new ExtensionError(
+ 'Using folders.delete() requires the "accountsFolders" and the "messagesDelete" permission'
+ );
+ }
+
+ let { folder } = getFolder({ accountId, path });
+ if (folder.server.type == "nntp") {
+ throw new ExtensionError(
+ `folders.delete() is not supported in news accounts`
+ );
+ }
+
+ if (folder.server.type == "imap") {
+ let inTrash = false;
+ let parent = folder.parent;
+ while (!inTrash && parent) {
+ inTrash = parent.flags & Ci.nsMsgFolderFlags.Trash;
+ parent = parent.parent;
+ }
+ if (inTrash) {
+ // FixMe: The UI is not updated, the folder is still shown, only after
+ // a restart it is removed from trash.
+ let deletedPromise = new Promise(resolve => {
+ MailServices.imap.deleteFolder(
+ folder,
+ {
+ OnStartRunningUrl() {},
+ OnStopRunningUrl(url, status) {
+ resolve(status);
+ },
+ },
+ null
+ );
+ });
+ let status = await deletedPromise;
+ if (!Components.isSuccessCode(status)) {
+ throw new ExtensionError(
+ `folders.delete() failed for unknown reasons`
+ );
+ }
+ } else {
+ // FixMe: Accounts could have their trash folder outside of their
+ // own folder structure.
+ let trash = folder.server.rootFolder.getFolderWithFlags(
+ Ci.nsMsgFolderFlags.Trash
+ );
+ let deletedPromise = new Promise(resolve => {
+ MailServices.imap.moveFolder(
+ folder,
+ trash,
+ {
+ OnStartRunningUrl() {},
+ OnStopRunningUrl(url, status) {
+ resolve(status);
+ },
+ },
+ null
+ );
+ });
+ let status = await deletedPromise;
+ if (!Components.isSuccessCode(status)) {
+ throw new ExtensionError(
+ `folders.delete() failed for unknown reasons`
+ );
+ }
+ }
+ } else {
+ let deletedPromise = waitForOperation(
+ MailServices.mfn.folderDeleted |
+ MailServices.mfn.folderMoveCopyCompleted,
+ folder.URI
+ );
+ folder.deleteSelf(null);
+ await deletedPromise;
+ }
+ },
+ async getFolderInfo({ accountId, path }) {
+ let { folder } = getFolder({ accountId, path });
+
+ let mailFolderInfo = {
+ favorite: folder.getFlag(Ci.nsMsgFolderFlags.Favorite),
+ totalMessageCount: folder.getTotalMessages(false),
+ unreadMessageCount: folder.getNumUnread(false),
+ };
+
+ return mailFolderInfo;
+ },
+ async getParentFolders({ accountId, path }, includeFolders) {
+ let { folder } = getFolder({ accountId, path });
+ let parentFolders = [];
+ // We do not consider the absolute root ("/") as a root folder, but
+ // the first real folders (all folders returned in MailAccount.folders
+ // are considered root folders).
+ while (folder.parent != null && folder.parent.parent != null) {
+ folder = folder.parent;
+
+ if (includeFolders) {
+ parentFolders.push(traverseSubfolders(folder, accountId));
+ } else {
+ parentFolders.push(convertFolder(folder, accountId));
+ }
+ }
+ return parentFolders;
+ },
+ async getSubFolders(accountOrFolder, includeFolders) {
+ let { folder, accountId } = getFolder(accountOrFolder);
+ let subFolders = [];
+ if (folder.hasSubFolders) {
+ for (let subFolder of folder.subFolders) {
+ if (includeFolders) {
+ subFolders.push(traverseSubfolders(subFolder, accountId));
+ } else {
+ subFolders.push(convertFolder(subFolder, accountId));
+ }
+ }
+ }
+ return subFolders;
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-identities.js b/comm/mail/components/extensions/parent/ext-identities.js
new file mode 100644
index 0000000000..1b9e719ebe
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-identities.js
@@ -0,0 +1,360 @@
+/* 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/. */
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailServices",
+ "resource:///modules/MailServices.jsm"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+});
+
+function findIdentityAndAccount(identityId) {
+ for (let account of MailServices.accounts.accounts) {
+ for (let identity of account.identities) {
+ if (identity.key == identityId) {
+ return { account, identity };
+ }
+ }
+ }
+ return null;
+}
+
+function checkForProtectedProperties(details) {
+ const protectedProperties = ["id", "accountId"];
+ for (let [key, value] of Object.entries(details)) {
+ // Check only properties explicitly provided.
+ if (value != null && protectedProperties.includes(key)) {
+ throw new ExtensionError(
+ `Setting the ${key} property of a MailIdentity is not supported.`
+ );
+ }
+ }
+}
+
+function updateIdentity(identity, details) {
+ for (let [key, value] of Object.entries(details)) {
+ // Update only properties explicitly provided.
+ if (value == null) {
+ continue;
+ }
+ // Map from WebExtension property names to nsIMsgIdentity property names.
+ switch (key) {
+ case "signatureIsPlainText":
+ identity.htmlSigFormat = !value;
+ break;
+ case "name":
+ identity.fullName = value;
+ break;
+ case "signature":
+ identity.htmlSigText = value;
+ break;
+ default:
+ identity[key] = value;
+ }
+ }
+}
+
+/**
+ * @implements {nsIObserver}
+ */
+var identitiesTracker = new (class extends EventEmitter {
+ constructor() {
+ super();
+ this.listenerCount = 0;
+
+ this.identities = new Map();
+ this.deferredNotifications = new ExtensionUtils.DefaultMap(
+ key =>
+ new DeferredTask(
+ () => this.emitPendingNotification(key),
+ NOTIFICATION_COLLAPSE_TIME
+ )
+ );
+
+ // Keep track of identities and their values, to suppress superfluous
+ // update notifications. The deferredTask timer is used to collapse multiple
+ // update notifications.
+ for (let account of MailServices.accounts.accounts) {
+ for (let identity of account.identities) {
+ this.identities.set(
+ identity.key,
+ convertMailIdentity(account, identity)
+ );
+ }
+ }
+ }
+
+ incrementListeners() {
+ this.listenerCount++;
+ if (this.listenerCount == 1) {
+ for (let topic of this._notifications) {
+ Services.obs.addObserver(this, topic);
+ }
+ Services.prefs.addObserver("mail.identity.", this);
+ }
+ }
+ decrementListeners() {
+ this.listenerCount--;
+ if (this.listenerCount == 0) {
+ for (let topic of this._notifications) {
+ Services.obs.removeObserver(this, topic);
+ }
+ Services.prefs.removeObserver("mail.identity.", this);
+ }
+ }
+
+ emitPendingNotification(key) {
+ let ia = findIdentityAndAccount(key);
+ if (!ia) {
+ return;
+ }
+
+ let oldValues = this.identities.get(key);
+ let newValues = convertMailIdentity(ia.account, ia.identity);
+ let changedValues = {};
+ for (let propertyName of Object.keys(newValues)) {
+ if (
+ !oldValues.hasOwnProperty(propertyName) ||
+ oldValues[propertyName] != newValues[propertyName]
+ ) {
+ changedValues[propertyName] = newValues[propertyName];
+ }
+ }
+ if (Object.keys(changedValues).length > 0) {
+ changedValues.accountId = ia.account.key;
+ changedValues.id = ia.identity.key;
+ let notification =
+ Object.keys(oldValues).length == 0
+ ? "account-identity-added"
+ : "account-identity-updated";
+ this.identities.set(key, newValues);
+ this.emit(notification, key, changedValues);
+ }
+ }
+
+ // nsIObserver
+ _notifications = ["account-identity-added", "account-identity-removed"];
+
+ async observe(subject, topic, data) {
+ switch (topic) {
+ case "account-identity-added":
+ {
+ let key = data;
+ this.identities.set(key, {});
+ this.deferredNotifications.get(key).arm();
+ }
+ break;
+
+ case "nsPref:changed":
+ {
+ let key = data.split(".").slice(2, 3).pop();
+
+ // Ignore update notifications for created identities, before they are
+ // added to an account (looks like they are cloned from a default
+ // identity). Also ignore notifications for deleted identities.
+ if (
+ key &&
+ this.identities.has(key) &&
+ this.identities.get(key) != null
+ ) {
+ this.deferredNotifications.get(key).disarm();
+ this.deferredNotifications.get(key).arm();
+ }
+ }
+ break;
+
+ case "account-identity-removed":
+ {
+ let key = data;
+ if (
+ key &&
+ this.identities.has(key) &&
+ this.identities.get(key) != null
+ ) {
+ // Mark identities as deleted instead of removing them.
+ this.identities.set(key, null);
+ // Force any pending notification to be emitted.
+ await this.deferredNotifications.get(key).finalize();
+
+ this.emit("account-identity-removed", key);
+ }
+ }
+ break;
+ }
+ }
+})();
+
+this.identities = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onCreated({ context, fire }) {
+ async function listener(event, key, identity) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(key, identity);
+ }
+ identitiesTracker.on("account-identity-added", listener);
+ return {
+ unregister: () => {
+ identitiesTracker.off("account-identity-added", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onUpdated({ context, fire }) {
+ async function listener(event, key, changedValues) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(key, changedValues);
+ }
+ identitiesTracker.on("account-identity-updated", listener);
+ return {
+ unregister: () => {
+ identitiesTracker.off("account-identity-updated", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onDeleted({ context, fire }) {
+ async function listener(event, key) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(key);
+ }
+ identitiesTracker.on("account-identity-removed", listener);
+ return {
+ unregister: () => {
+ identitiesTracker.off("account-identity-removed", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ constructor(...args) {
+ super(...args);
+ identitiesTracker.incrementListeners();
+ }
+
+ onShutdown() {
+ identitiesTracker.decrementListeners();
+ }
+
+ getAPI(context) {
+ return {
+ identities: {
+ async list(accountId) {
+ let accounts = accountId
+ ? [MailServices.accounts.getAccount(accountId)]
+ : MailServices.accounts.accounts;
+
+ let identities = [];
+ for (let account of accounts) {
+ for (let identity of account.identities) {
+ identities.push(convertMailIdentity(account, identity));
+ }
+ }
+ return identities;
+ },
+ async get(identityId) {
+ let ia = findIdentityAndAccount(identityId);
+ return ia ? convertMailIdentity(ia.account, ia.identity) : null;
+ },
+ async delete(identityId) {
+ let ia = findIdentityAndAccount(identityId);
+ if (!ia) {
+ throw new ExtensionError(`Identity not found: ${identityId}`);
+ }
+ if (
+ ia.account?.defaultIdentity &&
+ ia.account.defaultIdentity.key == ia.identity.key
+ ) {
+ throw new ExtensionError(
+ `Identity ${identityId} is the default identity of account ${ia.account.key} and cannot be deleted`
+ );
+ }
+ ia.account.removeIdentity(ia.identity);
+ },
+ async create(accountId, details) {
+ let account = MailServices.accounts.getAccount(accountId);
+ if (!account) {
+ throw new ExtensionError(`Account not found: ${accountId}`);
+ }
+ // Abort and throw, if details include protected properties.
+ checkForProtectedProperties(details);
+
+ let identity = MailServices.accounts.createIdentity();
+ updateIdentity(identity, details);
+ account.addIdentity(identity);
+ return convertMailIdentity(account, identity);
+ },
+ async update(identityId, details) {
+ let ia = findIdentityAndAccount(identityId);
+ if (!ia) {
+ throw new ExtensionError(`Identity not found: ${identityId}`);
+ }
+ // Abort and throw, if details include protected properties.
+ checkForProtectedProperties(details);
+
+ updateIdentity(ia.identity, details);
+ return convertMailIdentity(ia.account, ia.identity);
+ },
+ async getDefault(accountId) {
+ let account = MailServices.accounts.getAccount(accountId);
+ return convertMailIdentity(account, account?.defaultIdentity);
+ },
+ async setDefault(accountId, identityId) {
+ let account = MailServices.accounts.getAccount(accountId);
+ if (!account) {
+ throw new ExtensionError(`Account not found: ${accountId}`);
+ }
+ for (let identity of account.identities) {
+ if (identity.key == identityId) {
+ account.defaultIdentity = identity;
+ return;
+ }
+ }
+ throw new ExtensionError(
+ `Identity ${identityId} not found for ${accountId}`
+ );
+ },
+ onCreated: new EventManager({
+ context,
+ module: "identities",
+ event: "onCreated",
+ extensionApi: this,
+ }).api(),
+ onUpdated: new EventManager({
+ context,
+ module: "identities",
+ event: "onUpdated",
+ extensionApi: this,
+ }).api(),
+ onDeleted: new EventManager({
+ context,
+ module: "identities",
+ event: "onDeleted",
+ extensionApi: this,
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-mail.js b/comm/mail/components/extensions/parent/ext-mail.js
new file mode 100644
index 0000000000..31e86fe7b4
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-mail.js
@@ -0,0 +1,2883 @@
+/* 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/. */
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+var { ExtensionError, getInnerWindowID } = ExtensionUtils;
+var { defineLazyGetter, makeWidgetId } = ExtensionCommon;
+
+var { ExtensionSupport } = ChromeUtils.import(
+ "resource:///modules/ExtensionSupport.jsm"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionContent: "resource://gre/modules/ExtensionContent.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ MailServices: "resource:///modules/MailServices.jsm",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gJunkThreshold",
+ "mail.adaptivefilters.junk_threshold",
+ 90
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gMessagesPerPage",
+ "extensions.webextensions.messagesPerPage",
+ 100
+);
+XPCOMUtils.defineLazyGlobalGetters(this, [
+ "IOUtils",
+ "PathUtils",
+ "FileReader",
+]);
+
+const MAIN_WINDOW_URI = "chrome://messenger/content/messenger.xhtml";
+const POPUP_WINDOW_URI = "chrome://messenger/content/extensionPopup.xhtml";
+const COMPOSE_WINDOW_URI =
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml";
+const MESSAGE_WINDOW_URI = "chrome://messenger/content/messageWindow.xhtml";
+const MESSAGE_PROTOCOLS = ["imap", "mailbox", "news", "nntp", "snews"];
+
+const NOTIFICATION_COLLAPSE_TIME = 200;
+
+(function () {
+ // Monkey-patch all processes to add the "messenger" alias in all contexts.
+ Services.ppmm.loadProcessScript(
+ "chrome://messenger/content/processScript.js",
+ true
+ );
+
+ // This allows scripts to run in the compose document or message display
+ // document if and only if the extension has permission.
+ let { defaultConstructor } = ExtensionContent.contentScripts;
+ ExtensionContent.contentScripts.defaultConstructor = function (matcher) {
+ let script = defaultConstructor.call(this, matcher);
+
+ let { matchesWindowGlobal } = script;
+ script.matchesWindowGlobal = function (windowGlobal) {
+ let { browsingContext, windowContext } = windowGlobal;
+
+ if (
+ browsingContext.topChromeWindow?.location.href == COMPOSE_WINDOW_URI &&
+ windowContext.documentPrincipal.isNullPrincipal &&
+ windowContext.documentURI?.spec == "about:blank?compose"
+ ) {
+ return script.extension.hasPermission("compose");
+ }
+
+ if (MESSAGE_PROTOCOLS.includes(windowContext.documentURI?.scheme)) {
+ return script.extension.hasPermission("messagesModify");
+ }
+
+ return matchesWindowGlobal.apply(script, arguments);
+ };
+
+ return script;
+ };
+})();
+
+let tabTracker;
+let spaceTracker;
+let windowTracker;
+
+// This function is pretty tightly tied to Extension.jsm.
+// Its job is to fill in the |tab| property of the sender.
+const getSender = (extension, target, sender) => {
+ let tabId = -1;
+ if ("tabId" in sender) {
+ // The message came from a privileged extension page running in a tab. In
+ // that case, it should include a tabId property (which is filled in by the
+ // page-open listener below).
+ tabId = sender.tabId;
+ delete sender.tabId;
+ } else if (
+ ExtensionCommon.instanceOf(target, "XULFrameElement") ||
+ ExtensionCommon.instanceOf(target, "HTMLIFrameElement")
+ ) {
+ tabId = tabTracker.getBrowserData(target).tabId;
+ }
+
+ if (tabId != null && tabId >= 0) {
+ let tab = extension.tabManager.get(tabId, null);
+ if (tab) {
+ sender.tab = tab.convert();
+ }
+ }
+};
+
+// Used by Extension.jsm.
+global.tabGetSender = getSender;
+
+global.clickModifiersFromEvent = event => {
+ const map = {
+ shiftKey: "Shift",
+ altKey: "Alt",
+ metaKey: "Command",
+ ctrlKey: "Ctrl",
+ };
+ let modifiers = Object.keys(map)
+ .filter(key => event[key])
+ .map(key => map[key]);
+
+ if (event.ctrlKey && AppConstants.platform === "macosx") {
+ modifiers.push("MacCtrl");
+ }
+
+ return modifiers;
+};
+
+global.openOptionsPage = extension => {
+ let window = windowTracker.topNormalWindow;
+ if (!window) {
+ return Promise.reject({ message: "No mail window available" });
+ }
+
+ if (extension.manifest.options_ui.open_in_tab) {
+ window.switchToTabHavingURI(extension.manifest.options_ui.page, true, {
+ triggeringPrincipal: extension.principal,
+ });
+ return Promise.resolve();
+ }
+
+ let viewId = `addons://detail/${encodeURIComponent(
+ extension.id
+ )}/preferences`;
+
+ return window.openAddonsMgr(viewId);
+};
+
+/**
+ * Returns a real file for the given DOM File.
+ *
+ * @param {File} file - the DOM File
+ * @returns {nsIFile}
+ */
+async function getRealFileForFile(file) {
+ if (file.mozFullPath) {
+ let realFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ realFile.initWithPath(file.mozFullPath);
+ return realFile;
+ }
+
+ let pathTempFile = await IOUtils.createUniqueFile(
+ PathUtils.tempDir,
+ file.name.replaceAll(/[/:*?\"<>|]/g, "_"),
+ 0o600
+ );
+
+ let tempFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ tempFile.initWithPath(pathTempFile);
+ let extAppLauncher = Cc[
+ "@mozilla.org/uriloader/external-helper-app-service;1"
+ ].getService(Ci.nsPIExternalAppLauncher);
+ extAppLauncher.deleteTemporaryFileOnExit(tempFile);
+
+ let bytes = await new Promise(function (resolve) {
+ let reader = new FileReader();
+ reader.onloadend = function () {
+ resolve(new Uint8Array(reader.result));
+ };
+ reader.readAsArrayBuffer(file);
+ });
+
+ await IOUtils.write(pathTempFile, bytes);
+ return tempFile;
+}
+
+/**
+ * Gets the window for a tabmail tabInfo.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tabInfo object to get the browser for
+ * @returns {Window} - The browser element for the tab
+ */
+function getTabWindow(nativeTabInfo) {
+ return Cu.getGlobalForObject(nativeTabInfo);
+}
+global.getTabWindow = getTabWindow;
+
+/**
+ * Gets the tabmail for a tabmail tabInfo.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tabInfo object to get the browser for
+ * @returns {?XULElement} - The browser element for the tab
+ */
+function getTabTabmail(nativeTabInfo) {
+ return getTabWindow(nativeTabInfo).document.getElementById("tabmail");
+}
+global.getTabTabmail = getTabTabmail;
+
+/**
+ * Gets the tab browser for the tabmail tabInfo.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tabInfo object to get the browser for
+ * @returns {?XULElement} The browser element for the tab
+ */
+function getTabBrowser(nativeTabInfo) {
+ if (!nativeTabInfo) {
+ return null;
+ }
+
+ if (nativeTabInfo.mode) {
+ if (nativeTabInfo.mode.getBrowser) {
+ return nativeTabInfo.mode.getBrowser(nativeTabInfo);
+ }
+
+ if (nativeTabInfo.mode.tabType.getBrowser) {
+ return nativeTabInfo.mode.tabType.getBrowser(nativeTabInfo);
+ }
+ }
+
+ if (nativeTabInfo.ownerGlobal && nativeTabInfo.ownerGlobal.getBrowser) {
+ return nativeTabInfo.ownerGlobal.getBrowser();
+ }
+
+ return null;
+}
+global.getTabBrowser = getTabBrowser;
+
+/**
+ * Manages tab-specific and window-specific context data, and dispatches
+ * tab select events across all windows.
+ */
+global.TabContext = class extends EventEmitter {
+ /**
+ * @param {Function} getDefaultPrototype
+ * Provides the prototype of the context value for a tab or window when there is none.
+ * Called with a XULElement or ChromeWindow argument.
+ * Should return an object or null.
+ */
+ constructor(getDefaultPrototype) {
+ super();
+ this.getDefaultPrototype = getDefaultPrototype;
+ this.tabData = new WeakMap();
+ }
+
+ /**
+ * Returns the context data associated with `keyObject`.
+ *
+ * @param {XULElement|ChromeWindow} keyObject
+ * Browser tab or browser chrome window.
+ * @returns {object}
+ */
+ get(keyObject) {
+ if (!this.tabData.has(keyObject)) {
+ let data = Object.create(this.getDefaultPrototype(keyObject));
+ this.tabData.set(keyObject, data);
+ }
+
+ return this.tabData.get(keyObject);
+ }
+
+ /**
+ * Clears the context data associated with `keyObject`.
+ *
+ * @param {XULElement|ChromeWindow} keyObject
+ * Browser tab or browser chrome window.
+ */
+ clear(keyObject) {
+ this.tabData.delete(keyObject);
+ }
+};
+
+/* global searchInitialized */
+// This promise is used to wait for the search service to be initialized.
+// None of the code in the WebExtension modules requests that initialization.
+// It is assumed that it is started at some point. That might never happen,
+// e.g. if the application shuts down before the search service initializes.
+XPCOMUtils.defineLazyGetter(global, "searchInitialized", () => {
+ if (Services.search.isInitialized) {
+ return Promise.resolve();
+ }
+ return ExtensionUtils.promiseObserved(
+ "browser-search-service",
+ (_, data) => data == "init-complete"
+ );
+});
+
+/**
+ * Class for dummy message Headers.
+ */
+class nsDummyMsgHeader {
+ constructor(msgHdr) {
+ this.mProperties = [];
+ this.messageSize = 0;
+ this.author = null;
+ this.subject = "";
+ this.recipients = null;
+ this.ccList = null;
+ this.listPost = null;
+ this.messageId = null;
+ this.date = 0;
+ this.accountKey = "";
+ this.flags = 0;
+ // If you change us to return a fake folder, please update
+ // folderDisplay.js's FolderDisplayWidget's selectedMessageIsExternal getter.
+ this.folder = null;
+
+ if (msgHdr) {
+ for (let member of [
+ "accountKey",
+ "ccList",
+ "date",
+ "flags",
+ "listPost",
+ "messageId",
+ "messageSize",
+ ]) {
+ // Members are either (associative) arrays or primitives.
+ if (typeof msgHdr[member] == "object") {
+ this[member] = [];
+ for (let property in msgHdr[member]) {
+ this[member][property] = msgHdr[member][property];
+ }
+ } else {
+ this[member] = msgHdr[member];
+ }
+ }
+ this.author = msgHdr.mime2DecodedAuthor;
+ this.recipients = msgHdr.mime2DecodedRecipients;
+ this.subject = msgHdr.mime2DecodedSubject;
+ this.mProperties.dummyMsgUrl = msgHdr.getStringProperty("dummyMsgUrl");
+ this.mProperties.dummyMsgLastModifiedTime = msgHdr.getUint32Property(
+ "dummyMsgLastModifiedTime"
+ );
+ }
+ }
+ getProperty(aProperty) {
+ return this.getStringProperty(aProperty);
+ }
+ setProperty(aProperty, aVal) {
+ return this.setStringProperty(aProperty, aVal);
+ }
+ getStringProperty(aProperty) {
+ if (aProperty in this.mProperties) {
+ return this.mProperties[aProperty];
+ }
+ return "";
+ }
+ setStringProperty(aProperty, aVal) {
+ this.mProperties[aProperty] = aVal;
+ }
+ getUint32Property(aProperty) {
+ if (aProperty in this.mProperties) {
+ return parseInt(this.mProperties[aProperty]);
+ }
+ return 0;
+ }
+ setUint32Property(aProperty, aVal) {
+ this.mProperties[aProperty] = aVal.toString();
+ }
+ markHasAttachments(hasAttachments) {}
+ get mime2DecodedAuthor() {
+ return this.author;
+ }
+ get mime2DecodedSubject() {
+ return this.subject;
+ }
+ get mime2DecodedRecipients() {
+ return this.recipients;
+ }
+}
+
+/**
+ * Returns the WebExtension window type for the given window, or null, if it is
+ * not supported.
+ *
+ * @param {DOMWindow} window - The window to check
+ * @returns {[string]} - The WebExtension type of the window
+ */
+function getWebExtensionWindowType(window) {
+ let { documentElement } = window.document;
+ if (!documentElement) {
+ return null;
+ }
+ switch (documentElement.getAttribute("windowtype")) {
+ case "msgcompose":
+ return "messageCompose";
+ case "mail:messageWindow":
+ return "messageDisplay";
+ case "mail:extensionPopup":
+ return "popup";
+ case "mail:3pane":
+ return "normal";
+ default:
+ return "unknown";
+ }
+}
+
+/**
+ * The window tracker tracks opening and closing Thunderbird windows. Each window has an id, which
+ * is mapped to native window objects.
+ */
+class WindowTracker extends WindowTrackerBase {
+ /**
+ * Adds a tab progress listener to the given mail window.
+ *
+ * @param {DOMWindow} window - The mail window to which to add the listener.
+ * @param {object} listener - The listener to add
+ */
+ addProgressListener(window, listener) {
+ if (window.contentProgress) {
+ window.contentProgress.addListener(listener);
+ }
+ }
+
+ /**
+ * Removes a tab progress listener from the given mail window.
+ *
+ * @param {DOMWindow} window - The mail window from which to remove the listener.
+ * @param {object} listener - The listener to remove
+ */
+ removeProgressListener(window, listener) {
+ if (window.contentProgress) {
+ window.contentProgress.removeListener(listener);
+ }
+ }
+
+ /**
+ * Determines if the passed window object is supported by the windows API. The
+ * function name is for base class compatibility with toolkit.
+ *
+ * @param {DOMWindow} window - The window to check
+ * @returns {boolean} True, if the window is supported by the windows API
+ */
+ isBrowserWindow(window) {
+ let type = getWebExtensionWindowType(window);
+ return !!type && type != "unknown";
+ }
+
+ /**
+ * Determines if the passed window object is a mail window but not the main
+ * window. This is useful to find windows where the window itself is the
+ * "nativeTab" object in API terms.
+ *
+ * @param {DOMWindow} window - The window to check
+ * @returns {boolean} True, if the window is a mail window but not the main window
+ */
+ isSecondaryWindow(window) {
+ let { documentElement } = window.document;
+ if (!documentElement) {
+ return false;
+ }
+
+ return ["msgcompose", "mail:messageWindow", "mail:extensionPopup"].includes(
+ documentElement.getAttribute("windowtype")
+ );
+ }
+
+ /**
+ * The currently active, or topmost window supported by the API, or null if no
+ * supported window is currently open.
+ *
+ * @property {?DOMWindow} topWindow
+ * @readonly
+ */
+ get topWindow() {
+ let win = Services.wm.getMostRecentWindow(null);
+ // If we're lucky, this is a window supported by the API and we can return it
+ // directly.
+ if (win && !this.isBrowserWindow(win)) {
+ win = null;
+ // This is oldest to newest, so this gets a bit ugly.
+ for (let nextWin of Services.wm.getEnumerator(null)) {
+ if (this.isBrowserWindow(nextWin)) {
+ win = nextWin;
+ }
+ }
+ }
+ return win;
+ }
+
+ /**
+ * The currently active, or topmost window, or null if no window is currently open, that
+ * is not private browsing.
+ *
+ * @property {DOMWindow|null} topWindow
+ * @readonly
+ */
+ get topNonPBWindow() {
+ // Thunderbird does not support private browsing, return topWindow.
+ return this.topWindow;
+ }
+
+ /**
+ * The currently active, or topmost, mail window, or null if no mail window is currently open.
+ * Will only return the topmost "normal" (i.e., not popup) window.
+ *
+ * @property {?DOMWindow} topNormalWindow
+ * @readonly
+ */
+ get topNormalWindow() {
+ return Services.wm.getMostRecentWindow("mail:3pane");
+ }
+}
+
+/**
+ * Convenience class to keep track of and manage spaces.
+ */
+class SpaceTracker {
+ /**
+ * @typedef SpaceData
+ * @property {string} name - name of the space as used by the extension
+ * @property {integer} spaceId - id of the space as used by the tabs API
+ * @property {string} spaceButtonId - id of the button of this space in the
+ * spaces toolbar
+ * @property {string} defaultUrl - the url for the default space tab
+ * @property {ButtonProperties} buttonProperties
+ * @see mail/components/extensions/schemas/spaces.json
+ * @property {ExtensionData} extension - the extension the space belongs to
+ */
+
+ constructor() {
+ this._nextId = 1;
+ this._spaceData = new Map();
+ this._spaceIds = new Map();
+
+ // Keep this in sync with the default spaces in gSpacesToolbar.
+ let builtInSpaces = [
+ {
+ name: "mail",
+ spaceButtonId: "mailButton",
+ tabInSpace: tabInfo =>
+ ["folder", "mail3PaneTab", "mailMessageTab"].includes(
+ tabInfo.mode.name
+ )
+ ? 1
+ : 0,
+ },
+ {
+ name: "addressbook",
+ spaceButtonId: "addressBookButton",
+ tabInSpace: tabInfo => (tabInfo.mode.name == "addressBookTab" ? 1 : 0),
+ },
+ {
+ name: "calendar",
+ spaceButtonId: "calendarButton",
+ tabInSpace: tabInfo => (tabInfo.mode.name == "calendar" ? 1 : 0),
+ },
+ {
+ name: "tasks",
+ spaceButtonId: "tasksButton",
+ tabInSpace: tabInfo => (tabInfo.mode.name == "tasks" ? 1 : 0),
+ },
+ {
+ name: "chat",
+ spaceButtonId: "chatButton",
+ tabInSpace: tabInfo => (tabInfo.mode.name == "chat" ? 1 : 0),
+ },
+ {
+ name: "settings",
+ spaceButtonId: "settingsButton",
+ tabInSpace: tabInfo => {
+ switch (tabInfo.mode.name) {
+ case "preferencesTab":
+ // A primary tab that the open method creates.
+ return 1;
+ case "contentTab":
+ let url = tabInfo.urlbar?.value;
+ if (url == "about:accountsettings" || url == "about:addons") {
+ // A secondary tab, that is related to this space.
+ return 2;
+ }
+ }
+ return 0;
+ },
+ },
+ ];
+ for (let builtInSpace of builtInSpaces) {
+ this._add(builtInSpace);
+ }
+ }
+
+ findSpaceForTab(tabInfo) {
+ for (let spaceData of this._spaceData.values()) {
+ if (spaceData.tabInSpace(tabInfo)) {
+ return spaceData;
+ }
+ }
+ return undefined;
+ }
+
+ _add(spaceData) {
+ let spaceId = this._nextId++;
+ let { spaceButtonId } = spaceData;
+ this._spaceData.set(spaceButtonId, { ...spaceData, spaceId });
+ this._spaceIds.set(spaceId, spaceButtonId);
+ return { ...spaceData, spaceId };
+ }
+
+ /**
+ * Generate an id of the form <add-on-id>-spacesButton-<spaceId>.
+ *
+ * @param {string} name - name of the space as used by the extension
+ * @param {ExtensionData} extension
+ * @returns {string} id of the html element of the spaces toolbar button of
+ * this space
+ */
+ _getSpaceButtonId(name, extension) {
+ return `${makeWidgetId(extension.id)}-spacesButton-${name}`;
+ }
+
+ /**
+ * Get the SpaceData for the space with the given name for the given extension.
+ *
+ * @param {string} name - name of the space as used by the extension
+ * @param {ExtensionData} extension
+ * @returns {SpaceData}
+ */
+ fromSpaceName(name, extension) {
+ let spaceButtonId = this._getSpaceButtonId(name, extension);
+ return this.fromSpaceButtonId(spaceButtonId);
+ }
+
+ /**
+ * Get the SpaceData for the space with the given spaceId.
+ *
+ * @param {integer} spaceId - id of the space as used by the tabs API
+ * @returns {SpaceData}
+ */
+ fromSpaceId(spaceId) {
+ let spaceButtonId = this._spaceIds.get(spaceId);
+ return this.fromSpaceButtonId(spaceButtonId);
+ }
+
+ /**
+ * Get the SpaceData for the space with the given spaceButtonId.
+ *
+ * @param {string} spaceButtonId - id of the html element of a spaces toolbar
+ * button
+ * @returns {SpaceData}
+ */
+ fromSpaceButtonId(spaceButtonId) {
+ if (!spaceButtonId || !this._spaceData.has(spaceButtonId)) {
+ return null;
+ }
+ return this._spaceData.get(spaceButtonId);
+ }
+
+ /**
+ * Create a new space and return its SpaceData.
+ *
+ * @param {string} name - name of the space as used by the extension
+ * @param {string} defaultUrl - the url for the default space tab
+ * @param {ButtonProperties} buttonProperties
+ * @see mail/components/extensions/schemas/spaces.json
+ * @param {ExtensionData} extension - the extension the space belongs to
+ * @returns {SpaceData}
+ */
+ async create(name, defaultUrl, buttonProperties, extension) {
+ let spaceButtonId = this._getSpaceButtonId(name, extension);
+ if (this._spaceData.has(spaceButtonId)) {
+ return false;
+ }
+ return this._add({
+ name,
+ spaceButtonId,
+ tabInSpace: tabInfo => (tabInfo.spaceButtonId == spaceButtonId ? 1 : 0),
+ defaultUrl,
+ buttonProperties,
+ extension,
+ });
+ }
+
+ /**
+ * Return a WebExtension Space object, representing the given spaceData.
+ *
+ * @param {SpaceData} spaceData
+ * @returns {Space} - @see mail/components/extensions/schemas/spaces.json
+ */
+ convert(spaceData, extension) {
+ let space = {
+ id: spaceData.spaceId,
+ name: spaceData.name,
+ isBuiltIn: !spaceData.extension,
+ isSelfOwned: spaceData.extension?.id == extension.id,
+ };
+ if (spaceData.extension && extension.hasPermission("management")) {
+ space.extensionId = spaceData.extension.id;
+ }
+ return space;
+ }
+
+ /**
+ * Remove a space and its SpaceData from the tracker.
+ *
+ * @param {SpaceData} spaceData
+ */
+ remove(spaceData) {
+ if (!this._spaceData.has(spaceData.spaceButtonId)) {
+ return;
+ }
+ this._spaceData.delete(spaceData.spaceButtonId);
+ }
+
+ /**
+ * Update spaceData for a space in the tracker.
+ *
+ * @param {SpaceData} spaceData
+ */
+ update(spaceData) {
+ if (!this._spaceData.has(spaceData.spaceButtonId)) {
+ return;
+ }
+ this._spaceData.set(spaceData.spaceButtonId, spaceData);
+ }
+
+ /**
+ * Return the SpaceData of all spaces known to the tracker.
+ *
+ * @returns {SpaceData[]}
+ */
+ getAll() {
+ return this._spaceData.values();
+ }
+}
+
+/**
+ * Tracks the opening and closing of tabs and maps them between their numeric WebExtension ID and
+ * the native tab info objects.
+ */
+class TabTracker extends TabTrackerBase {
+ constructor() {
+ super();
+
+ this._tabs = new WeakMap();
+ this._browsers = new Map();
+ this._tabIds = new Map();
+ this._nextId = 1;
+ this._movingTabs = new Map();
+
+ this._handleTabDestroyed = this._handleTabDestroyed.bind(this);
+
+ ExtensionSupport.registerWindowListener("ext-sessions", {
+ chromeURLs: [MAIN_WINDOW_URI],
+ onLoadWindow(window) {
+ window.gTabmail.registerTabMonitor({
+ monitorName: "extensionSession",
+ onTabTitleChanged(aTab) {},
+ onTabClosing(aTab) {},
+ onTabPersist(aTab) {
+ return aTab._ext.extensionSession;
+ },
+ onTabRestored(aTab, aState) {
+ aTab._ext.extensionSession = aState;
+ },
+ onTabSwitched(aNewTab, aOldTab) {},
+ onTabOpened(aTab) {},
+ });
+ },
+ });
+ }
+
+ /**
+ * Initialize tab tracking listeners the first time that an event listener is added.
+ */
+ init() {
+ if (this.initialized) {
+ return;
+ }
+ this.initialized = true;
+
+ this._handleWindowOpen = this._handleWindowOpen.bind(this);
+ this._handleWindowClose = this._handleWindowClose.bind(this);
+
+ windowTracker.addListener("TabClose", this);
+ windowTracker.addListener("TabOpen", this);
+ windowTracker.addListener("TabSelect", this);
+ windowTracker.addOpenListener(this._handleWindowOpen);
+ windowTracker.addCloseListener(this._handleWindowClose);
+
+ /* eslint-disable mozilla/balanced-listeners */
+ this.on("tab-detached", this._handleTabDestroyed);
+ this.on("tab-removed", this._handleTabDestroyed);
+ /* eslint-enable mozilla/balanced-listeners */
+ }
+
+ /**
+ * Returns the numeric ID for the given native tab.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tabmail tabInfo for which to return an ID
+ * @returns {Integer} The tab's numeric ID
+ */
+ getId(nativeTabInfo) {
+ let id = this._tabs.get(nativeTabInfo);
+ if (id) {
+ return id;
+ }
+
+ this.init();
+
+ id = this._nextId++;
+ this.setId(nativeTabInfo, id);
+ return id;
+ }
+
+ /**
+ * Returns the tab id corresponding to the given browser element.
+ *
+ * @param {XULElement} browser - The <browser> element to retrieve for
+ * @returns {Integer} The tab's numeric ID
+ */
+ getBrowserTabId(browser) {
+ let id = this._browsers.get(browser.browserId);
+ if (id) {
+ return id;
+ }
+
+ let window = browser.browsingContext.topChromeWindow;
+ let tabmail = window.document.getElementById("tabmail");
+ let tab = tabmail && tabmail.getTabForBrowser(browser);
+
+ if (tab) {
+ id = this.getId(tab);
+ this._browsers.set(browser.browserId, id);
+ return id;
+ }
+ if (windowTracker.isSecondaryWindow(window)) {
+ return this.getId(window);
+ }
+ return -1;
+ }
+
+ /**
+ * Records the tab information for the given tabInfo object.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tab info to record for
+ * @param {Integer} id - The tab id to record
+ */
+ setId(nativeTabInfo, id) {
+ this._tabs.set(nativeTabInfo, id);
+ let browser = getTabBrowser(nativeTabInfo);
+ if (browser) {
+ this._browsers.set(browser.browserId, id);
+ }
+ this._tabIds.set(id, nativeTabInfo);
+ }
+
+ /**
+ * Function to call when a tab was close, deletes tab information for the tab.
+ *
+ * @param {Event} event - The event triggering the detroyal
+ * @param {{ nativeTabInfo:NativeTabInfo}} - The object containing tab info
+ */
+ _handleTabDestroyed(event, { nativeTabInfo }) {
+ let id = this._tabs.get(nativeTabInfo);
+ if (id) {
+ this._tabs.delete(nativeTabInfo);
+ if (nativeTabInfo.browser) {
+ this._browsers.delete(nativeTabInfo.browser.browserId);
+ }
+ if (this._tabIds.get(id) === nativeTabInfo) {
+ this._tabIds.delete(id);
+ }
+ }
+ }
+
+ /**
+ * Returns the native tab with the given numeric ID.
+ *
+ * @param {Integer} tabId - The numeric ID of the tab to return.
+ * @param {*} default_ - The value to return if no tab exists with the given ID.
+ * @returns {NativeTabInfo} The tab information for the given id.
+ */
+ getTab(tabId, default_ = undefined) {
+ let nativeTabInfo = this._tabIds.get(tabId);
+ if (nativeTabInfo) {
+ return nativeTabInfo;
+ }
+ if (default_ !== undefined) {
+ return default_;
+ }
+ throw new ExtensionError(`Invalid tab ID: ${tabId}`);
+ }
+
+ /**
+ * Handles load events for recently-opened windows, and adds additional
+ * listeners which may only be safely added when the window is fully loaded.
+ *
+ * @param {Event} event - A DOM event to handle.
+ */
+ handleEvent(event) {
+ let nativeTabInfo = event.detail.tabInfo;
+
+ switch (event.type) {
+ case "TabOpen": {
+ // Save the current tab, since the newly-created tab will likely be
+ // active by the time the promise below resolves and the event is
+ // dispatched.
+ let tabmail = event.target.ownerDocument.getElementById("tabmail");
+ let currentTab = tabmail.selectedTab;
+ // We need to delay sending this event until the next tick, since the
+ // tab does not have its final index when the TabOpen event is dispatched.
+ Promise.resolve().then(() => {
+ if (event.detail.moving) {
+ let srcTabId = this._movingTabs.get(event.detail.moving);
+ this.setId(nativeTabInfo, srcTabId);
+ this._movingTabs.delete(event.detail.moving);
+
+ this.emitAttached(nativeTabInfo);
+ } else {
+ this.emitCreated(nativeTabInfo, currentTab);
+ }
+ });
+ break;
+ }
+
+ case "TabClose": {
+ if (event.detail.moving) {
+ this._movingTabs.set(event.detail.moving, this.getId(nativeTabInfo));
+ this.emitDetached(nativeTabInfo);
+ } else {
+ this.emitRemoved(nativeTabInfo, false);
+ }
+ break;
+ }
+
+ case "TabSelect":
+ // Because we are delaying calling emitCreated above, we also need to
+ // delay sending this event because it shouldn't fire before onCreated.
+ Promise.resolve().then(() => {
+ this.emitActivated(nativeTabInfo, event.detail.previousTabInfo);
+ });
+ break;
+ }
+ }
+
+ /**
+ * A private method which is called whenever a new mail window is opened, and dispatches the
+ * necessary events for it.
+ *
+ * @param {DOMWindow} window - The window being opened.
+ */
+ _handleWindowOpen(window) {
+ if (windowTracker.isSecondaryWindow(window)) {
+ this.emit("tab-created", {
+ nativeTabInfo: window,
+ currentTab: window,
+ });
+ return;
+ }
+
+ let tabmail = window.document.getElementById("tabmail");
+ if (!tabmail) {
+ return;
+ }
+
+ for (let nativeTabInfo of tabmail.tabInfo) {
+ this.emitCreated(nativeTabInfo);
+ }
+ }
+
+ /**
+ * A private method which is called whenever a mail window is closed, and dispatches the necessary
+ * events for it.
+ *
+ * @param {DOMWindow} window - The window being closed.
+ */
+ _handleWindowClose(window) {
+ if (windowTracker.isSecondaryWindow(window)) {
+ this.emit("tab-removed", {
+ nativeTabInfo: window,
+ tabId: this.getId(window),
+ windowId: windowTracker.getId(getTabWindow(window)),
+ isWindowClosing: true,
+ });
+ return;
+ }
+
+ let tabmail = window.document.getElementById("tabmail");
+ if (!tabmail) {
+ return;
+ }
+
+ for (let nativeTabInfo of tabmail.tabInfo) {
+ this.emitRemoved(nativeTabInfo, true);
+ }
+ }
+
+ /**
+ * Emits a "tab-activated" event for the given tab info.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tab info which has been activated.
+ * @param {NativeTab} previousTabInfo - The previously active tab element.
+ */
+ emitActivated(nativeTabInfo, previousTabInfo) {
+ let previousTabId;
+ if (previousTabInfo && !previousTabInfo.closed) {
+ previousTabId = this.getId(previousTabInfo);
+ }
+ this.emit("tab-activated", {
+ tabId: this.getId(nativeTabInfo),
+ previousTabId,
+ windowId: windowTracker.getId(getTabWindow(nativeTabInfo)),
+ });
+ }
+
+ /**
+ * Emits a "tab-attached" event for the given tab info.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tab info which is being attached.
+ */
+ emitAttached(nativeTabInfo) {
+ let tabId = this.getId(nativeTabInfo);
+ let browser = getTabBrowser(nativeTabInfo);
+ let tabmail = browser.ownerDocument.getElementById("tabmail");
+ let tabIndex = tabmail._getTabContextForTabbyThing(nativeTabInfo)[0];
+ let newWindowId = windowTracker.getId(browser.ownerGlobal);
+
+ this.emit("tab-attached", {
+ nativeTabInfo,
+ tabId,
+ newWindowId,
+ newPosition: tabIndex,
+ });
+ }
+
+ /**
+ * Emits a "tab-detached" event for the given tab info.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tab info which is being detached.
+ */
+ emitDetached(nativeTabInfo) {
+ let tabId = this.getId(nativeTabInfo);
+ let browser = getTabBrowser(nativeTabInfo);
+ let tabmail = browser.ownerDocument.getElementById("tabmail");
+ let tabIndex = tabmail._getTabContextForTabbyThing(nativeTabInfo)[0];
+ let oldWindowId = windowTracker.getId(browser.ownerGlobal);
+
+ this.emit("tab-detached", {
+ nativeTabInfo,
+ tabId,
+ oldWindowId,
+ oldPosition: tabIndex,
+ });
+ }
+
+ /**
+ * Emits a "tab-created" event for the given tab info.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tab info which is being created.
+ * @param {?NativeTab} currentTab - The tab info for the currently active tab.
+ */
+ emitCreated(nativeTabInfo, currentTab) {
+ this.emit("tab-created", { nativeTabInfo, currentTab });
+ }
+
+ /**
+ * Emits a "tab-removed" event for the given tab info.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The tab info in the window to which the tab is being
+ * removed
+ * @param {boolean} isWindowClosing - If true, the window with these tabs is closing
+ */
+ emitRemoved(nativeTabInfo, isWindowClosing) {
+ this.emit("tab-removed", {
+ nativeTabInfo,
+ tabId: this.getId(nativeTabInfo),
+ windowId: windowTracker.getId(getTabWindow(nativeTabInfo)),
+ isWindowClosing,
+ });
+ }
+
+ /**
+ * Returns tab id and window id for the given browser element.
+ *
+ * @param {Element} browser - The browser element to check
+ * @returns {{ tabId:Integer, windowId:Integer }} The browsing data for the element
+ */
+ getBrowserData(browser) {
+ return {
+ tabId: this.getBrowserTabId(browser),
+ windowId: windowTracker.getId(browser.ownerGlobal),
+ };
+ }
+
+ /**
+ * Returns the active tab info for the given window
+ *
+ * @property {?NativeTabInfo} activeTab The active tab
+ * @readonly
+ */
+ get activeTab() {
+ let window = windowTracker.topWindow;
+ let tabmail = window && window.document.getElementById("tabmail");
+ return tabmail ? tabmail.selectedTab : window;
+ }
+}
+
+tabTracker = new TabTracker();
+spaceTracker = new SpaceTracker();
+windowTracker = new WindowTracker();
+Object.assign(global, { tabTracker, spaceTracker, windowTracker });
+
+/**
+ * Extension-specific wrapper around a Thunderbird tab. Note that for actual
+ * tabs in the main window, some of these methods are overridden by the
+ * TabmailTab subclass.
+ */
+class Tab extends TabBase {
+ get spaceId() {
+ let tabWindow = getTabWindow(this.nativeTab);
+ if (getWebExtensionWindowType(tabWindow) != "normal") {
+ return undefined;
+ }
+
+ let spaceData = spaceTracker.findSpaceForTab(this.nativeTab);
+ return spaceData?.spaceId ?? undefined;
+ }
+
+ /** What sort of tab is this? */
+ get type() {
+ switch (this.nativeTab.location?.href) {
+ case COMPOSE_WINDOW_URI:
+ return "messageCompose";
+ case MESSAGE_WINDOW_URI:
+ return "messageDisplay";
+ case POPUP_WINDOW_URI:
+ return "content";
+ default:
+ return null;
+ }
+ }
+
+ /** Overrides the matches function to enable querying for tab types. */
+ matches(queryInfo, context) {
+ // If the query includes url or title, but this is a non-browser tab, return
+ // false directly.
+ if ((queryInfo.url || queryInfo.title) && !this.browser) {
+ return false;
+ }
+ let result = super.matches(queryInfo, context);
+
+ let type = queryInfo.mailTab ? "mail" : queryInfo.type;
+ if (result && type && this.type != type) {
+ return false;
+ }
+
+ if (result && queryInfo.spaceId && this.spaceId != queryInfo.spaceId) {
+ return false;
+ }
+
+ return result;
+ }
+
+ /** Adds the mailTab property and removes some useless properties from a tab object. */
+ convert(fallback) {
+ let result = super.convert(fallback);
+ result.spaceId = this.spaceId;
+ result.type = this.type;
+ result.mailTab = result.type == "mail";
+
+ // These properties are not useful to Thunderbird extensions and are not returned.
+ for (let key of [
+ "attention",
+ "audible",
+ "discarded",
+ "hidden",
+ "incognito",
+ "isArticle",
+ "isInReaderMode",
+ "lastAccessed",
+ "mutedInfo",
+ "pinned",
+ "sharingState",
+ "successorTabId",
+ ]) {
+ delete result[key];
+ }
+
+ return result;
+ }
+
+ /** Always returns false. This feature doesn't exist in Thunderbird. */
+ get _incognito() {
+ return false;
+ }
+
+ /** Returns the XUL browser for the tab. */
+ get browser() {
+ if (this.type == "messageCompose") {
+ return this.nativeTab.GetCurrentEditorElement();
+ }
+ if (this.nativeTab.getBrowser) {
+ return this.nativeTab.getBrowser();
+ }
+ return null;
+ }
+
+ get innerWindowID() {
+ if (!this.browser) {
+ return null;
+ }
+ if (this.type == "messageCompose") {
+ return this.browser.contentWindow.windowUtils.currentInnerWindowID;
+ }
+ return super.innerWindowID;
+ }
+
+ /** Returns the frame loader for the tab. */
+ get frameLoader() {
+ // If we don't have a frameLoader yet, just return a dummy with no width and
+ // height.
+ return super.frameLoader || { lazyWidth: 0, lazyHeight: 0 };
+ }
+
+ /** Returns false if the current tab does not have a url associated. */
+ get matchesHostPermission() {
+ if (!this._url) {
+ return false;
+ }
+ return super.matchesHostPermission;
+ }
+
+ /** Returns the current URL of this tab, without permission checks. */
+ get _url() {
+ if (this.type == "messageCompose") {
+ return undefined;
+ }
+ return this.browser?.currentURI?.spec;
+ }
+
+ /** Returns the current title of this tab, without permission checks. */
+ get _title() {
+ if (this.browser && this.browser.contentTitle) {
+ return this.browser.contentTitle;
+ }
+ return this.nativeTab.label;
+ }
+
+ /** Returns the favIcon, without permission checks. */
+ get _favIconUrl() {
+ return null;
+ }
+
+ /** Returns the last accessed time. */
+ get lastAccessed() {
+ return 0;
+ }
+
+ /** Returns the audible state. */
+ get audible() {
+ return false;
+ }
+
+ /** Returns the cookie store id. */
+ get cookieStoreId() {
+ if (this.browser && this.browser.contentPrincipal) {
+ return getCookieStoreIdForOriginAttributes(
+ this.browser.contentPrincipal.originAttributes
+ );
+ }
+
+ return DEFAULT_STORE;
+ }
+
+ /** Returns the discarded state. */
+ get discarded() {
+ return false;
+ }
+
+ /** Returns the tab height. */
+ get height() {
+ return this.frameLoader.lazyHeight;
+ }
+
+ /** Returns hidden status. */
+ get hidden() {
+ return false;
+ }
+
+ /** Returns the tab index. */
+ get index() {
+ return 0;
+ }
+
+ /** Returns information about the muted state of the tab. */
+ get mutedInfo() {
+ return { muted: false };
+ }
+
+ /** Returns information about the sharing state of the tab. */
+ get sharingState() {
+ return { camera: false, microphone: false, screen: false };
+ }
+
+ /** Returns the pinned state of the tab. */
+ get pinned() {
+ return false;
+ }
+
+ /** Returns the active state of the tab. */
+ get active() {
+ return true;
+ }
+
+ /** Returns the highlighted state of the tab. */
+ get highlighted() {
+ return this.active;
+ }
+
+ /** Returns the selected state of the tab. */
+ get selected() {
+ return this.active;
+ }
+
+ /** Returns the loading status of the tab. */
+ get status() {
+ let isComplete;
+ switch (this.type) {
+ case "messageDisplay":
+ case "addressBook":
+ isComplete = this.browser?.contentDocument?.readyState == "complete";
+ break;
+ case "mail":
+ {
+ // If the messagePane is hidden or all browsers are hidden, there is
+ // nothing to be loaded and we should return complete.
+ let about3Pane = this.nativeTab.chromeBrowser.contentWindow;
+ isComplete =
+ !about3Pane.paneLayout?.messagePaneVisible ||
+ this.browser?.webProgress?.isLoadingDocument === false ||
+ (about3Pane.webBrowser?.hidden &&
+ about3Pane.messageBrowser?.hidden &&
+ about3Pane.multiMessageBrowser?.hidden);
+ }
+ break;
+ case "content":
+ case "special":
+ isComplete = this.browser?.webProgress?.isLoadingDocument === false;
+ break;
+ default:
+ // All other tabs (chat, task, calendar, messageCompose) do not fire the
+ // tabs.onUpdated event (Bug 1827929). Let them always be complete.
+ isComplete = true;
+ }
+ return isComplete ? "complete" : "loading";
+ }
+
+ /** Returns the width of the tab. */
+ get width() {
+ return this.frameLoader.lazyWidth;
+ }
+
+ /** Returns the native window object of the tab. */
+ get window() {
+ return this.nativeTab;
+ }
+
+ /** Returns the window id of the tab. */
+ get windowId() {
+ return windowTracker.getId(this.window);
+ }
+
+ /** Returns the attention state of the tab. */
+ get attention() {
+ return false;
+ }
+
+ /** Returns the article state of the tab. */
+ get isArticle() {
+ return false;
+ }
+
+ /** Returns the reader mode state of the tab. */
+ get isInReaderMode() {
+ return false;
+ }
+
+ /** Returns the id of the successor tab of the tab. */
+ get successorTabId() {
+ return -1;
+ }
+}
+
+class TabmailTab extends Tab {
+ constructor(extension, nativeTab, id) {
+ if (nativeTab.localName == "tab") {
+ let tabmail = nativeTab.ownerDocument.getElementById("tabmail");
+ nativeTab = tabmail._getTabContextForTabbyThing(nativeTab)[1];
+ }
+ super(extension, nativeTab, id);
+ }
+
+ /** What sort of tab is this? */
+ get type() {
+ switch (this.nativeTab.mode.name) {
+ case "mail3PaneTab":
+ return "mail";
+ case "addressBookTab":
+ return "addressBook";
+ case "mailMessageTab":
+ return "messageDisplay";
+ case "contentTab": {
+ let currentURI = this.nativeTab.browser.currentURI;
+ if (currentURI?.schemeIs("about")) {
+ switch (currentURI.filePath) {
+ case "accountprovisioner":
+ return "accountProvisioner";
+ case "blank":
+ return "content";
+ default:
+ return "special";
+ }
+ }
+ if (currentURI?.schemeIs("chrome")) {
+ return "special";
+ }
+ return "content";
+ }
+ case "calendar":
+ case "calendarEvent":
+ case "calendarTask":
+ case "tasks":
+ case "chat":
+ return this.nativeTab.mode.name;
+ case "provisionerCheckoutTab":
+ case "glodaFacet":
+ case "preferencesTab":
+ return "special";
+ default:
+ // We should not get here, unless a new type is registered with tabmail.
+ return null;
+ }
+ }
+
+ /** Returns the XUL browser for the tab. */
+ get browser() {
+ return getTabBrowser(this.nativeTab);
+ }
+
+ /** Returns the favIcon, without permission checks. */
+ get _favIconUrl() {
+ return this.nativeTab.favIconUrl;
+ }
+
+ /** Returns the tabmail element for the tab. */
+ get tabmail() {
+ return getTabTabmail(this.nativeTab);
+ }
+
+ /** Returns the tab index. */
+ get index() {
+ return this.tabmail.tabInfo.indexOf(this.nativeTab);
+ }
+
+ /** Returns the active state of the tab. */
+ get active() {
+ return this.nativeTab == this.tabmail.selectedTab;
+ }
+
+ /** Returns the title of the tab, without permission checks. */
+ get _title() {
+ if (this.browser && this.browser.contentTitle) {
+ return this.browser.contentTitle;
+ }
+ // Do we want to be using this.nativeTab.title instead? The difference is
+ // that the tabNode label may use defaultTabTitle instead, but do we want to
+ // send this out?
+ return this.nativeTab.tabNode.getAttribute("label");
+ }
+
+ /** Returns the native window object of the tab. */
+ get window() {
+ return this.tabmail.ownerGlobal;
+ }
+}
+
+/**
+ * Extension-specific wrapper around a Thunderbird window.
+ */
+class Window extends WindowBase {
+ /**
+ * @property {string} type - The type of the window, as defined by the
+ * WebExtension API.
+ * @see mail/components/extensions/schemas/windows.json
+ * @readonly
+ */
+ get type() {
+ let type = getWebExtensionWindowType(this.window);
+ if (!type) {
+ throw new ExtensionError(
+ "Windows API encountered an invalid window type."
+ );
+ }
+ return type;
+ }
+
+ /** Returns the title of the tab, without permission checks. */
+ get _title() {
+ return this.window.document.title;
+ }
+
+ /** Returns the title of the tab, checking tab permissions. */
+ get title() {
+ // Thunderbird can have an empty active tab while a window is loading
+ if (this.activeTab && this.activeTab.hasTabPermission) {
+ return this._title;
+ }
+ return null;
+ }
+
+ /**
+ * Sets the title preface of the window.
+ *
+ * @param {string} titlePreface - The title preface to set
+ */
+ setTitlePreface(titlePreface) {
+ this.window.document.documentElement.setAttribute(
+ "titlepreface",
+ titlePreface
+ );
+ }
+
+ /** Gets the foucsed state of the window. */
+ get focused() {
+ return this.window.document.hasFocus();
+ }
+
+ /** Gets the top position of the window. */
+ get top() {
+ return this.window.screenY;
+ }
+
+ /** Gets the left position of the window. */
+ get left() {
+ return this.window.screenX;
+ }
+
+ /** Gets the width of the window. */
+ get width() {
+ return this.window.outerWidth;
+ }
+
+ /** Gets the height of the window. */
+ get height() {
+ return this.window.outerHeight;
+ }
+
+ /** Gets the private browsing status of the window. */
+ get incognito() {
+ return false;
+ }
+
+ /** Checks if the window is considered always on top. */
+ get alwaysOnTop() {
+ return this.appWindow.zLevel >= Ci.nsIAppWindow.raisedZ;
+ }
+
+ /** Checks if the window was the last one focused. */
+ get isLastFocused() {
+ return this.window === windowTracker.topWindow;
+ }
+
+ /**
+ * Returns the window state for the given window.
+ *
+ * @param {DOMWindow} window - The window to check
+ * @returns {string} "maximized", "minimized", "normal" or "fullscreen"
+ */
+ static getState(window) {
+ const STATES = {
+ [window.STATE_MAXIMIZED]: "maximized",
+ [window.STATE_MINIMIZED]: "minimized",
+ [window.STATE_NORMAL]: "normal",
+ };
+ let state = STATES[window.windowState];
+ if (window.fullScreen) {
+ state = "fullscreen";
+ }
+ return state;
+ }
+
+ /** Returns the window state for this specific window. */
+ get state() {
+ return Window.getState(this.window);
+ }
+
+ /**
+ * Sets the window state for this specific window.
+ *
+ * @param {string} state - "maximized", "minimized", "normal" or "fullscreen"
+ */
+ async setState(state) {
+ let { window } = this;
+ const expectedState = (function () {
+ switch (state) {
+ case "maximized":
+ return window.STATE_MAXIMIZED;
+ case "minimized":
+ case "docked":
+ return window.STATE_MINIMIZED;
+ case "normal":
+ return window.STATE_NORMAL;
+ case "fullscreen":
+ return window.STATE_FULLSCREEN;
+ }
+ throw new ExtensionError(`Unexpected window state: ${state}`);
+ })();
+
+ const initialState = window.windowState;
+ if (expectedState == initialState) {
+ return;
+ }
+
+ // We check for window.fullScreen here to make sure to exit fullscreen even
+ // if DOM and widget disagree on what the state is. This is a speculative
+ // fix for bug 1780876, ideally it should not be needed.
+ if (initialState == window.STATE_FULLSCREEN || window.fullScreen) {
+ window.fullScreen = false;
+ }
+
+ switch (expectedState) {
+ case window.STATE_MAXIMIZED:
+ window.maximize();
+ break;
+ case window.STATE_MINIMIZED:
+ window.minimize();
+ break;
+
+ case window.STATE_NORMAL:
+ // Restore sometimes returns the window to its previous state, rather
+ // than to the "normal" state, so it may need to be called anywhere from
+ // zero to two times.
+ window.restore();
+ if (window.windowState !== window.STATE_NORMAL) {
+ window.restore();
+ }
+ if (window.windowState !== window.STATE_NORMAL) {
+ // And on OS-X, where normal vs. maximized is basically a heuristic,
+ // we need to cheat.
+ window.sizeToContent();
+ }
+ break;
+
+ case window.STATE_FULLSCREEN:
+ window.fullScreen = true;
+ break;
+
+ default:
+ throw new ExtensionError(`Unexpected window state: ${state}`);
+ }
+
+ if (window.windowState != expectedState) {
+ // On Linux, sizemode changes are asynchronous. Some of them might not
+ // even happen if the window manager doesn't want to, so wait for a bit
+ // instead of forever for a sizemode change that might not ever happen.
+ const noWindowManagerTimeout = 2000;
+
+ let onSizeModeChange;
+ const promiseExpectedSizeMode = new Promise(resolve => {
+ onSizeModeChange = function () {
+ if (window.windowState == expectedState) {
+ resolve();
+ }
+ };
+ window.addEventListener("sizemodechange", onSizeModeChange);
+ });
+
+ await Promise.any([
+ promiseExpectedSizeMode,
+ new Promise(resolve =>
+ window.setTimeout(resolve, noWindowManagerTimeout)
+ ),
+ ]);
+ window.removeEventListener("sizemodechange", onSizeModeChange);
+ }
+
+ if (window.windowState != expectedState) {
+ console.warn(
+ `Window manager refused to set window to state ${expectedState}.`
+ );
+ }
+ }
+
+ /**
+ * Retrieves the (relevant) tabs in this window.
+ *
+ * @yields {Tab} The wrapped Tab in this window
+ */
+ *getTabs() {
+ let { tabManager } = this.extension;
+ yield tabManager.getWrapper(this.window);
+ }
+
+ /**
+ * Returns an iterator of TabBase objects for the highlighted tab in this
+ * window. This is an alias for the active tab.
+ *
+ * @returns {Iterator<TabBase>}
+ */
+ *getHighlightedTabs() {
+ yield this.activeTab;
+ }
+
+ /** Retrieves the active tab in this window */
+ get activeTab() {
+ let { tabManager } = this.extension;
+ return tabManager.getWrapper(this.window);
+ }
+
+ /**
+ * Retrieves the tab at the given index.
+ *
+ * @param {number} index - The index to look at
+ * @returns {Tab} The wrapped tab at the index
+ */
+ getTabAtIndex(index) {
+ let { tabManager } = this.extension;
+ if (index == 0) {
+ return tabManager.getWrapper(this.window);
+ }
+ return null;
+ }
+}
+
+class TabmailWindow extends Window {
+ /** Returns the tabmail element for the tab. */
+ get tabmail() {
+ return this.window.document.getElementById("tabmail");
+ }
+
+ /**
+ * Retrieves the (relevant) tabs in this window.
+ *
+ * @yields {Tab} The wrapped Tab in this window
+ */
+ *getTabs() {
+ let { tabManager } = this.extension;
+
+ for (let nativeTabInfo of this.tabmail.tabInfo) {
+ // Only tabs that have a browser element.
+ yield tabManager.getWrapper(nativeTabInfo);
+ }
+ }
+
+ /** Retrieves the active tab in this window */
+ get activeTab() {
+ let { tabManager } = this.extension;
+ let selectedTab = this.tabmail.selectedTab;
+ if (selectedTab) {
+ return tabManager.getWrapper(selectedTab);
+ }
+ return null;
+ }
+
+ /**
+ * Retrieves the tab at the given index.
+ *
+ * @param {number} index - The index to look at
+ * @returns {Tab} The wrapped tab at the index
+ */
+ getTabAtIndex(index) {
+ let { tabManager } = this.extension;
+ let nativeTabInfo = this.tabmail.tabInfo[index];
+ if (nativeTabInfo) {
+ return tabManager.getWrapper(nativeTabInfo);
+ }
+ return null;
+ }
+}
+
+Object.assign(global, { Tab, Window });
+
+/**
+ * Manages native tabs, their wrappers, and their dynamic permissions for a particular extension.
+ */
+class TabManager extends TabManagerBase {
+ /**
+ * Returns a Tab wrapper for the tab with the given ID.
+ *
+ * @param {integer} tabId - The ID of the tab for which to return a wrapper.
+ * @param {*} default_ - The value to return if no tab exists with the given ID.
+ * @returns {Tab|*} The wrapped tab, or the default value
+ */
+ get(tabId, default_ = undefined) {
+ let nativeTabInfo = tabTracker.getTab(tabId, default_);
+
+ if (nativeTabInfo) {
+ return this.getWrapper(nativeTabInfo);
+ }
+ return default_;
+ }
+
+ /**
+ * If the extension has requested activeTab permission, grant it those permissions for the current
+ * inner window in the given native tab.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The native tab for which to grant permissions.
+ */
+ addActiveTabPermission(nativeTabInfo = tabTracker.activeTab) {
+ if (nativeTabInfo.browser) {
+ super.addActiveTabPermission(nativeTabInfo);
+ }
+ }
+
+ /**
+ * Revoke the extension's activeTab permissions for the current inner window of the given native
+ * tab.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The native tab for which to revoke permissions.
+ */
+ revokeActiveTabPermission(nativeTabInfo = tabTracker.activeTab) {
+ super.revokeActiveTabPermission(nativeTabInfo);
+ }
+
+ /**
+ * Determines access using extension context.
+ *
+ * @param {NativeTab} nativeTab
+ * The tab to check access on.
+ * @returns {boolean}
+ * True if the extension has permissions for this tab.
+ */
+ canAccessTab(nativeTab) {
+ return true;
+ }
+
+ /**
+ * Returns a new Tab instance wrapping the given native tab info.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - The native tab for which to return a wrapper.
+ * @returns {Tab} The wrapped native tab
+ */
+ wrapTab(nativeTabInfo) {
+ let tabClass = TabmailTab;
+ if (nativeTabInfo instanceof Ci.nsIDOMWindow) {
+ tabClass = Tab;
+ }
+ return new tabClass(
+ this.extension,
+ nativeTabInfo,
+ tabTracker.getId(nativeTabInfo)
+ );
+ }
+}
+
+/**
+ * Manages native browser windows and their wrappers for a particular extension.
+ */
+class WindowManager extends WindowManagerBase {
+ /**
+ * Returns a Window wrapper for the mail window with the given ID.
+ *
+ * @param {Integer} windowId - The ID of the browser window for which to return a wrapper.
+ * @param {BaseContext} context - The extension context for which the matching is being performed.
+ * Used to determine the current window for relevant properties.
+ * @returns {Window} The wrapped window
+ */
+ get(windowId, context) {
+ let window = windowTracker.getWindow(windowId, context);
+ return this.getWrapper(window);
+ }
+
+ /**
+ * Yields an iterator of WindowBase wrappers for each currently existing browser window.
+ *
+ * @yields {Window}
+ */
+ *getAll() {
+ for (let window of windowTracker.browserWindows()) {
+ yield this.getWrapper(window);
+ }
+ }
+
+ /**
+ * Returns a new Window instance wrapping the given mail window.
+ *
+ * @param {DOMWindow} window - The mail window for which to return a wrapper.
+ * @returns {Window} The wrapped window
+ */
+ wrapWindow(window) {
+ let windowClass = Window;
+ if (
+ window.document.documentElement.getAttribute("windowtype") == "mail:3pane"
+ ) {
+ windowClass = TabmailWindow;
+ }
+ return new windowClass(this.extension, window, windowTracker.getId(window));
+ }
+}
+
+/**
+ * Wait until the normal window identified by the given windowId has finished its
+ * delayed startup. Returns its DOMWindow when done. Waits for the top normal
+ * window, if no window is specified.
+ *
+ * @param {*} [context] - a WebExtension context
+ * @param {*} [windowId] - a WebExtension window id
+ * @returns {DOMWindow}
+ */
+async function getNormalWindowReady(context, windowId) {
+ let window;
+ if (windowId) {
+ let win = context.extension.windowManager.get(windowId, context);
+ if (win.type != "normal") {
+ throw new ExtensionError(
+ `Window with ID ${windowId} is not a normal window`
+ );
+ }
+ window = win.window;
+ } else {
+ window = windowTracker.topNormalWindow;
+ }
+
+ // Wait for session restore.
+ await new Promise(resolve => {
+ if (!window.SessionStoreManager._restored) {
+ let obs = (observedWindow, topic, data) => {
+ if (observedWindow != window) {
+ return;
+ }
+ Services.obs.removeObserver(obs, "mail-tabs-session-restored");
+ resolve();
+ };
+ Services.obs.addObserver(obs, "mail-tabs-session-restored");
+ } else {
+ resolve();
+ }
+ });
+
+ // Wait for all mail3PaneTab's to have been fully restored and loaded.
+ for (let tabInfo of window.gTabmail.tabInfo) {
+ let { chromeBrowser, mode, closed } = tabInfo;
+ if (!closed && mode.name == "mail3PaneTab") {
+ await new Promise(resolve => {
+ if (
+ chromeBrowser.contentDocument.readyState == "complete" &&
+ chromeBrowser.currentURI.spec == "about:3pane"
+ ) {
+ resolve();
+ } else {
+ chromeBrowser.contentWindow.addEventListener(
+ "load",
+ () => resolve(),
+ {
+ once: true,
+ }
+ );
+ }
+ });
+ }
+ }
+
+ return window;
+}
+
+/**
+ * Converts an nsIMsgAccount to a simple object
+ *
+ * @param {nsIMsgAccount} account
+ * @returns {object}
+ */
+function convertAccount(account, includeFolders = true) {
+ if (!account) {
+ return null;
+ }
+
+ account = account.QueryInterface(Ci.nsIMsgAccount);
+ let server = account.incomingServer;
+ if (server.type == "im") {
+ return null;
+ }
+
+ let folders = null;
+ if (includeFolders) {
+ folders = traverseSubfolders(
+ account.incomingServer.rootFolder,
+ account.key
+ ).subFolders;
+ }
+
+ return {
+ id: account.key,
+ name: account.incomingServer.prettyName,
+ type: account.incomingServer.type,
+ folders,
+ identities: account.identities.map(identity =>
+ convertMailIdentity(account, identity)
+ ),
+ };
+}
+
+/**
+ * Converts an nsIMsgIdentity to a simple object for use in messages.
+ *
+ * @param {nsIMsgAccount} account
+ * @param {nsIMsgIdentity} identity
+ * @returns {object}
+ */
+function convertMailIdentity(account, identity) {
+ if (!account || !identity) {
+ return null;
+ }
+ identity = identity.QueryInterface(Ci.nsIMsgIdentity);
+ return {
+ accountId: account.key,
+ id: identity.key,
+ label: identity.label || "",
+ name: identity.fullName || "",
+ email: identity.email || "",
+ replyTo: identity.replyTo || "",
+ organization: identity.organization || "",
+ composeHtml: identity.composeHtml,
+ signature: identity.htmlSigText || "",
+ signatureIsPlainText: !identity.htmlSigFormat,
+ };
+}
+
+/**
+ * The following functions turn nsIMsgFolder references into more human-friendly forms.
+ * A folder can be referenced with the account key, and the path to the folder in that account.
+ */
+
+/**
+ * Convert a folder URI to a human-friendly path.
+ *
+ * @returns {string}
+ */
+function folderURIToPath(accountId, uri) {
+ let server = MailServices.accounts.getAccount(accountId).incomingServer;
+ let rootURI = server.rootFolder.URI;
+ if (rootURI == uri) {
+ return "/";
+ }
+ // The .URI property of an IMAP folder doesn't have %-encoded characters, but
+ // may include literal % chars. Services.io.newURI(uri) applies encodeURI to
+ // the returned filePath, but will not encode any literal % chars, which will
+ // cause decodeURIComponent to fail (bug 1707408).
+ if (server.type == "imap") {
+ return uri.substring(rootURI.length);
+ }
+ let path = Services.io.newURI(uri).filePath;
+ return path.split("/").map(decodeURIComponent).join("/");
+}
+
+/**
+ * Convert a human-friendly path to a folder URI. This function does not assume
+ * that the folder referenced exists.
+ *
+ * @returns {string}
+ */
+function folderPathToURI(accountId, path) {
+ let server = MailServices.accounts.getAccount(accountId).incomingServer;
+ let rootURI = server.rootFolder.URI;
+ if (path == "/") {
+ return rootURI;
+ }
+ // The .URI property of an IMAP folder doesn't have %-encoded characters.
+ // If encoded here, the folder lookup service won't find the folder.
+ if (server.type == "imap") {
+ return rootURI + path;
+ }
+ return (
+ rootURI +
+ path
+ .split("/")
+ .map(p =>
+ encodeURIComponent(p)
+ .replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16))
+ // We do not encode "+" chars in folder URIs. Manually convert them
+ // back to literal + chars, otherwise folder lookup will fail.
+ .replaceAll("%2B", "+")
+ )
+ .join("/")
+ );
+}
+
+const folderTypeMap = new Map([
+ [Ci.nsMsgFolderFlags.Inbox, "inbox"],
+ [Ci.nsMsgFolderFlags.Drafts, "drafts"],
+ [Ci.nsMsgFolderFlags.SentMail, "sent"],
+ [Ci.nsMsgFolderFlags.Trash, "trash"],
+ [Ci.nsMsgFolderFlags.Templates, "templates"],
+ [Ci.nsMsgFolderFlags.Archive, "archives"],
+ [Ci.nsMsgFolderFlags.Junk, "junk"],
+ [Ci.nsMsgFolderFlags.Queue, "outbox"],
+]);
+
+/**
+ * Converts an nsIMsgFolder to a simple object for use in API messages.
+ *
+ * @param {nsIMsgFolder} folder - The folder to convert.
+ * @param {string} [accountId] - An optimization to avoid looking up the
+ * account. The value from nsIMsgHdr.accountKey must not be used here.
+ * @returns {MailFolder}
+ * @see mail/components/extensions/schemas/folders.json
+ */
+function convertFolder(folder, accountId) {
+ if (!folder) {
+ return null;
+ }
+ if (!accountId) {
+ let server = folder.server;
+ let account = MailServices.accounts.FindAccountForServer(server);
+ accountId = account.key;
+ }
+
+ let folderObject = {
+ accountId,
+ name: folder.prettyName,
+ path: folderURIToPath(accountId, folder.URI),
+ };
+
+ for (let [flag, typeName] of folderTypeMap.entries()) {
+ if (folder.flags & flag) {
+ folderObject.type = typeName;
+ }
+ }
+
+ return folderObject;
+}
+
+/**
+ * Converts an nsIMsgFolder and all its subfolders to a simple object for use in
+ * API messages.
+ *
+ * @param {nsIMsgFolder} folder - The folder to convert.
+ * @param {string} [accountId] - An optimization to avoid looking up the
+ * account. The value from nsIMsgHdr.accountKey must not be used here.
+ * @returns {MailFolder}
+ * @see mail/components/extensions/schemas/folders.json
+ */
+function traverseSubfolders(folder, accountId) {
+ let f = convertFolder(folder, accountId);
+ f.subFolders = [];
+ if (folder.hasSubFolders) {
+ // Use the same order as used by Thunderbird.
+ let subFolders = [...folder.subFolders].sort((a, b) =>
+ a.sortOrder == b.sortOrder
+ ? a.name.localeCompare(b.name)
+ : a.sortOrder - b.sortOrder
+ );
+ for (let subFolder of subFolders) {
+ f.subFolders.push(
+ traverseSubfolders(subFolder, accountId || f.accountId)
+ );
+ }
+ }
+ return f;
+}
+
+class FolderManager {
+ constructor(extension) {
+ this.extension = extension;
+ }
+
+ convert(folder, accountId) {
+ return convertFolder(folder, accountId);
+ }
+
+ get(accountId, path) {
+ return MailServices.folderLookup.getFolderForURL(
+ folderPathToURI(accountId, path)
+ );
+ }
+}
+
+/**
+ * Checks if the provided nsIMsgHdr is a dummy message header of an attached message.
+ */
+function isAttachedMessage(msgHdr) {
+ try {
+ return (
+ !msgHdr.folder &&
+ new URL(msgHdr.getStringProperty("dummyMsgUrl")).searchParams.has("part")
+ );
+ } catch (ex) {
+ return false;
+ }
+}
+
+/**
+ * Converts an nsIMsgHdr to a simple object for use in messages.
+ * This function WILL change as the API develops.
+ *
+ * @param {nsIMsgHdr} msgHdr
+ * @param {ExtensionData} extension
+ * @returns {MessageHeader} MessageHeader object
+ *
+ * @see /mail/components/extensions/schemas/messages.json
+ */
+function convertMessage(msgHdr, extension) {
+ if (!msgHdr) {
+ return null;
+ }
+
+ let composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ let junkScore = parseInt(msgHdr.getStringProperty("junkscore"), 10) || 0;
+ let tags = (msgHdr.getStringProperty("keywords") || "")
+ .split(" ")
+ .filter(MailServices.tags.isValidKey);
+
+ let external = !msgHdr.folder;
+
+ // Getting the size of attached messages does not work consistently. For imap://
+ // and mailbox:// messages the returned size in msgHdr.messageSize is 0, and for
+ // file:// messages the returned size is always the total file size
+ // Be consistent here and always return 0. The user can obtain the message size
+ // from the size of the associated attachment file.
+ let size = isAttachedMessage(msgHdr) ? 0 : msgHdr.messageSize;
+
+ let messageObject = {
+ id: messageTracker.getId(msgHdr),
+ date: new Date(Math.round(msgHdr.date / 1000)),
+ author: msgHdr.mime2DecodedAuthor,
+ recipients: composeFields.splitRecipients(
+ msgHdr.mime2DecodedRecipients,
+ false
+ ),
+ ccList: composeFields.splitRecipients(msgHdr.ccList, false),
+ bccList: composeFields.splitRecipients(msgHdr.bccList, false),
+ subject: msgHdr.mime2DecodedSubject,
+ read: msgHdr.isRead,
+ new: !!(msgHdr.flags & Ci.nsMsgMessageFlags.New),
+ headersOnly: !!(msgHdr.flags & Ci.nsMsgMessageFlags.Partial),
+ flagged: !!msgHdr.isFlagged,
+ junk: junkScore >= gJunkThreshold,
+ junkScore,
+ headerMessageId: msgHdr.messageId,
+ size,
+ tags,
+ external,
+ };
+ // convertMessage can be called without providing an extension, if the info is
+ // needed for multiple extensions. The caller has to ensure that the folder info
+ // is not forwarded to extensions, which do not have the required permission.
+ if (
+ msgHdr.folder &&
+ (!extension || extension.hasPermission("accountsRead"))
+ ) {
+ messageObject.folder = convertFolder(msgHdr.folder);
+ }
+ return messageObject;
+}
+
+/**
+ * A map of numeric identifiers to messages for easy reference.
+ *
+ * @implements {nsIFolderListener}
+ * @implements {nsIMsgFolderListener}
+ * @implements {nsIObserver}
+ */
+var messageTracker = new (class extends EventEmitter {
+ constructor() {
+ super();
+ this._nextId = 1;
+ this._messages = new Map();
+ this._messageIds = new Map();
+ this._listenerCount = 0;
+ this._pendingKeyChanges = new Map();
+ this._dummyMessageHeaders = new Map();
+
+ // nsIObserver
+ Services.obs.addObserver(this, "quit-application-granted");
+ Services.obs.addObserver(this, "attachment-delete-msgkey-changed");
+ // nsIFolderListener
+ MailServices.mailSession.AddFolderListener(
+ this,
+ Ci.nsIFolderListener.propertyFlagChanged |
+ Ci.nsIFolderListener.intPropertyChanged
+ );
+ // nsIMsgFolderListener
+ MailServices.mfn.addListener(
+ this,
+ MailServices.mfn.msgsJunkStatusChanged |
+ MailServices.mfn.msgsDeleted |
+ MailServices.mfn.msgsMoveCopyCompleted |
+ MailServices.mfn.msgKeyChanged
+ );
+
+ this._messageOpenListener = {
+ registered: false,
+ async handleEvent(event) {
+ let msgHdr = event.detail;
+ // It is not possible to retrieve the dummyMsgHdr of messages opened
+ // from file at a later time, track them manually.
+ if (
+ msgHdr &&
+ !msgHdr.folder &&
+ msgHdr.getStringProperty("dummyMsgUrl").startsWith("file://")
+ ) {
+ messageTracker.getId(msgHdr);
+ }
+ },
+ };
+ try {
+ windowTracker.addListener("MsgLoaded", this._messageOpenListener);
+ this._messageOpenListener.registered = true;
+ } catch (ex) {
+ // Fails during XPCSHELL tests, which mock the WindowWatcher but do not
+ // implement registerNotification.
+ }
+ }
+
+ cleanup() {
+ // nsIObserver
+ Services.obs.removeObserver(this, "quit-application-granted");
+ Services.obs.removeObserver(this, "attachment-delete-msgkey-changed");
+ // nsIFolderListener
+ MailServices.mailSession.RemoveFolderListener(this);
+ // nsIMsgFolderListener
+ MailServices.mfn.removeListener(this);
+ if (this._messageOpenListener.registered) {
+ windowTracker.removeListener("MsgLoaded", this._messageOpenListener);
+ this._messageOpenListener.registered = false;
+ }
+ }
+
+ /**
+ * Maps the provided message identifier to the given messageTracker id.
+ */
+ _set(id, msgIdentifier, msgHdr) {
+ let hash = JSON.stringify(msgIdentifier);
+ this._messageIds.set(hash, id);
+ this._messages.set(id, msgIdentifier);
+ // Keep track of dummy message headers, which do not have a folderURI property
+ // and cannot be retrieved later.
+ if (msgHdr && !msgHdr.folder) {
+ this._dummyMessageHeaders.set(msgIdentifier.dummyMsgUrl, msgHdr);
+ }
+ }
+
+ /**
+ * Lookup the messageTracker id for the given message identifier, return null
+ * if not known.
+ */
+ _get(msgIdentifier) {
+ let hash = JSON.stringify(msgIdentifier);
+ if (this._messageIds.has(hash)) {
+ return this._messageIds.get(hash);
+ }
+ return null;
+ }
+
+ /**
+ * Removes the provided message identifier from the messageTracker.
+ */
+ _remove(msgIdentifier) {
+ let hash = JSON.stringify(msgIdentifier);
+ let id = this._get(msgIdentifier);
+ this._messages.delete(id);
+ this._messageIds.delete(hash);
+ this._dummyMessageHeaders.delete(msgIdentifier.dummyMsgUrl);
+ }
+
+ /**
+ * Finds a message in the messageTracker or adds it.
+ *
+ * @returns {int} The messageTracker id of the message
+ */
+ getId(msgHdr) {
+ let msgIdentifier;
+ if (msgHdr.folder) {
+ msgIdentifier = {
+ folderURI: msgHdr.folder.URI,
+ messageKey: msgHdr.messageKey,
+ };
+ } else {
+ // Normalize the dummyMsgUrl by sorting its parameters and striping them
+ // to a minimum.
+ let url = new URL(msgHdr.getStringProperty("dummyMsgUrl"));
+ let parameters = Array.from(url.searchParams, p => p[0]).filter(
+ p => !["group", "number", "key", "part"].includes(p)
+ );
+ for (let parameter of parameters) {
+ url.searchParams.delete(parameter);
+ }
+ url.searchParams.sort();
+
+ msgIdentifier = {
+ dummyMsgUrl: url.href,
+ dummyMsgLastModifiedTime: msgHdr.getUint32Property(
+ "dummyMsgLastModifiedTime"
+ ),
+ };
+ }
+
+ let id = this._get(msgIdentifier);
+ if (id) {
+ return id;
+ }
+ id = this._nextId++;
+
+ this._set(id, msgIdentifier, new nsDummyMsgHeader(msgHdr));
+ return id;
+ }
+
+ /**
+ * Check if the provided msgIdentifier belongs to a modified file message.
+ *
+ * @param {*} msgIdentifier - the msgIdentifier object of the message
+ * @returns {boolean}
+ */
+ isModifiedFileMsg(msgIdentifier) {
+ if (!msgIdentifier.dummyMsgUrl?.startsWith("file://")) {
+ return false;
+ }
+
+ try {
+ let file = Services.io
+ .newURI(msgIdentifier.dummyMsgUrl)
+ .QueryInterface(Ci.nsIFileURL).file;
+ if (!file?.exists()) {
+ throw new ExtensionError("File does not exist");
+ }
+ if (
+ msgIdentifier.dummyMsgLastModifiedTime &&
+ Math.floor(file.lastModifiedTime / 1000000) !=
+ msgIdentifier.dummyMsgLastModifiedTime
+ ) {
+ throw new ExtensionError("File has been modified");
+ }
+ } catch (ex) {
+ console.error(ex);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Retrieves a message from the messageTracker. If the message no longer,
+ * exists it is removed from the messageTracker.
+ *
+ * @returns {nsIMsgHdr} The identifier of the message
+ */
+ getMessage(id) {
+ let msgIdentifier = this._messages.get(id);
+ if (!msgIdentifier) {
+ return null;
+ }
+
+ if (msgIdentifier.folderURI) {
+ let folder = MailServices.folderLookup.getFolderForURL(
+ msgIdentifier.folderURI
+ );
+ if (folder) {
+ let msgHdr = folder.msgDatabase.getMsgHdrForKey(
+ msgIdentifier.messageKey
+ );
+ if (msgHdr) {
+ return msgHdr;
+ }
+ }
+ } else {
+ let msgHdr = this._dummyMessageHeaders.get(msgIdentifier.dummyMsgUrl);
+ if (msgHdr && !this.isModifiedFileMsg(msgIdentifier)) {
+ return msgHdr;
+ }
+ }
+
+ this._remove(msgIdentifier);
+ return null;
+ }
+
+ // nsIFolderListener
+
+ onFolderPropertyFlagChanged(item, property, oldFlag, newFlag) {
+ let changes = {};
+ switch (property) {
+ case "Status":
+ if ((oldFlag ^ newFlag) & Ci.nsMsgMessageFlags.Read) {
+ changes.read = item.isRead;
+ }
+ if ((oldFlag ^ newFlag) & Ci.nsMsgMessageFlags.New) {
+ changes.new = !!(newFlag & Ci.nsMsgMessageFlags.New);
+ }
+ break;
+ case "Flagged":
+ changes.flagged = item.isFlagged;
+ break;
+ case "Keywords":
+ {
+ let tags = item.getStringProperty("keywords");
+ tags = tags ? tags.split(" ") : [];
+ changes.tags = tags.filter(MailServices.tags.isValidKey);
+ }
+ break;
+ }
+ if (Object.keys(changes).length) {
+ this.emit("message-updated", item, changes);
+ }
+ }
+
+ onFolderIntPropertyChanged(folder, property, oldValue, newValue) {
+ switch (property) {
+ case "BiffState":
+ if (newValue == Ci.nsIMsgFolder.nsMsgBiffState_NewMail) {
+ // The folder argument is a root folder.
+ this.findNewMessages(folder);
+ }
+ break;
+ case "NewMailReceived":
+ // The folder argument is a real folder.
+ this.findNewMessages(folder);
+ break;
+ }
+ }
+
+ /**
+ * Finds all folders with new messages in the specified changedFolder and
+ * returns those.
+ *
+ * @see MailNotificationManager._getFirstRealFolderWithNewMail()
+ */
+ findNewMessages(changedFolder) {
+ let folders = changedFolder.descendants;
+ folders.unshift(changedFolder);
+ for (let folder of folders) {
+ let flags = folder.flags;
+ if (
+ !(flags & Ci.nsMsgFolderFlags.Inbox) &&
+ flags & (Ci.nsMsgFolderFlags.SpecialUse | Ci.nsMsgFolderFlags.Virtual)
+ ) {
+ // Do not notify if the folder is not Inbox but one of
+ // Drafts|Trash|SentMail|Templates|Junk|Archive|Queue or Virtual.
+ continue;
+ }
+ let numNewMessages = folder.getNumNewMessages(false);
+ if (!numNewMessages) {
+ continue;
+ }
+ let msgDb = folder.msgDatabase;
+ let newMsgKeys = msgDb.getNewList().slice(-numNewMessages);
+ if (newMsgKeys.length == 0) {
+ continue;
+ }
+ this.emit(
+ "messages-received",
+ folder,
+ newMsgKeys.map(key => msgDb.getMsgHdrForKey(key))
+ );
+ }
+ }
+
+ // nsIMsgFolderListener
+
+ msgsJunkStatusChanged(messages) {
+ for (let msgHdr of messages) {
+ let junkScore = parseInt(msgHdr.getStringProperty("junkscore"), 10) || 0;
+ this.emit("message-updated", msgHdr, {
+ junk: junkScore >= gJunkThreshold,
+ });
+ }
+ }
+
+ msgsDeleted(deletedMsgs) {
+ if (deletedMsgs.length > 0) {
+ this.emit("messages-deleted", deletedMsgs);
+ }
+ }
+
+ msgsMoveCopyCompleted(move, srcMsgs, dstFolder, dstMsgs) {
+ if (srcMsgs.length > 0 && dstMsgs.length > 0) {
+ let emitMsg = move ? "messages-moved" : "messages-copied";
+ this.emit(emitMsg, srcMsgs, dstMsgs);
+ }
+ }
+
+ msgKeyChanged(oldKey, newMsgHdr) {
+ // For IMAP messages there is a delayed update of database keys and if those
+ // keys change, the messageTracker needs to update its maps, otherwise wrong
+ // messages will be returned. Key changes are replayed in multi-step swaps.
+ let newKey = newMsgHdr.messageKey;
+
+ // Replay pending swaps.
+ while (this._pendingKeyChanges.has(oldKey)) {
+ let next = this._pendingKeyChanges.get(oldKey);
+ this._pendingKeyChanges.delete(oldKey);
+ oldKey = next;
+
+ // Check if we are left with a no-op swap and exit early.
+ if (oldKey == newKey) {
+ this._pendingKeyChanges.delete(oldKey);
+ return;
+ }
+ }
+
+ if (oldKey != newKey) {
+ // New key swap, log the mirror swap as pending.
+ this._pendingKeyChanges.set(newKey, oldKey);
+
+ // Swap tracker entries.
+ let oldId = this._get({
+ folderURI: newMsgHdr.folder.URI,
+ messageKey: oldKey,
+ });
+ let newId = this._get({
+ folderURI: newMsgHdr.folder.URI,
+ messageKey: newKey,
+ });
+ this._set(oldId, { folderURI: newMsgHdr.folder.URI, messageKey: newKey });
+ this._set(newId, { folderURI: newMsgHdr.folder.URI, messageKey: oldKey });
+ }
+ }
+
+ // nsIObserver
+
+ /**
+ * Observer to update message tracker if a message has received a new key due
+ * to attachments being removed, which we do not consider to be a new message.
+ */
+ observe(subject, topic, data) {
+ if (topic == "attachment-delete-msgkey-changed") {
+ data = JSON.parse(data);
+
+ if (data && data.folderURI && data.oldMessageKey && data.newMessageKey) {
+ let id = this._get({
+ folderURI: data.folderURI,
+ messageKey: data.oldMessageKey,
+ });
+ if (id) {
+ // Replace tracker entries.
+ this._set(id, {
+ folderURI: data.folderURI,
+ messageKey: data.newMessageKey,
+ });
+ }
+ }
+ } else if (topic == "quit-application-granted") {
+ this.cleanup();
+ }
+ }
+})();
+
+/**
+ * Tracks lists of messages so that an extension can consume them in chunks.
+ * Any WebExtensions method that could return multiple messages should instead call
+ * messageListTracker.startList and return the results, which contain the first
+ * chunk. Further chunks can be fetched by the extension calling
+ * browser.messages.continueList. Chunk size is controlled by a pref.
+ */
+var messageListTracker = {
+ _contextLists: new WeakMap(),
+
+ /**
+ * Takes an array or enumerator of messages and returns the first chunk.
+ *
+ * @returns {object}
+ */
+ startList(messages, extension) {
+ let messageList = this.createList(extension);
+ if (Array.isArray(messages)) {
+ messages = this._createEnumerator(messages);
+ }
+ while (messages.hasMoreElements()) {
+ let next = messages.getNext();
+ messageList.add(next.QueryInterface(Ci.nsIMsgDBHdr));
+ }
+ messageList.done();
+ return this.getNextPage(messageList);
+ },
+
+ _createEnumerator(array) {
+ let current = 0;
+ return {
+ hasMoreElements() {
+ return current < array.length;
+ },
+ getNext() {
+ return array[current++];
+ },
+ };
+ },
+
+ /**
+ * Creates and returns a new messageList object.
+ *
+ * @returns {object}
+ */
+ createList(extension) {
+ let messageListId = Services.uuid.generateUUID().number.substring(1, 37);
+ let messageList = this._createListObject(messageListId, extension);
+ let lists = this._contextLists.get(extension);
+ if (!lists) {
+ lists = new Map();
+ this._contextLists.set(extension, lists);
+ }
+ lists.set(messageListId, messageList);
+ return messageList;
+ },
+
+ /**
+ * Returns the messageList object for a given id.
+ *
+ * @returns {object}
+ */
+ getList(messageListId, extension) {
+ let lists = this._contextLists.get(extension);
+ let messageList = lists ? lists.get(messageListId, null) : null;
+ if (!messageList) {
+ throw new ExtensionError(
+ `No message list for id ${messageListId}. Have you reached the end of a list?`
+ );
+ }
+ return messageList;
+ },
+
+ /**
+ * Returns the first/next message page of the given messageList.
+ *
+ * @returns {object}
+ */
+ async getNextPage(messageList) {
+ let messageListId = messageList.id;
+ let messages = await messageList.getNextPage();
+ if (!messageList.hasMorePages()) {
+ let lists = this._contextLists.get(messageList.extension);
+ if (lists && lists.has(messageListId)) {
+ lists.delete(messageListId);
+ }
+ messageListId = null;
+ }
+ return {
+ id: messageListId,
+ messages,
+ };
+ },
+
+ _createListObject(messageListId, extension) {
+ function getCurrentPage() {
+ return pages.length > 0 ? pages[pages.length - 1] : null;
+ }
+
+ function addPage() {
+ let contents = getCurrentPage();
+ let resolvePage = currentPageResolveCallback;
+
+ pages.push([]);
+ pagePromises.push(
+ new Promise(resolve => {
+ currentPageResolveCallback = resolve;
+ })
+ );
+
+ if (contents && resolvePage) {
+ resolvePage(contents);
+ }
+ }
+
+ let _messageListId = messageListId;
+ let _extension = extension;
+ let isDone = false;
+ let pages = [];
+ let pagePromises = [];
+ let currentPageResolveCallback = null;
+ let readIndex = 0;
+
+ // Add first page.
+ addPage();
+
+ return {
+ get id() {
+ return _messageListId;
+ },
+ get extension() {
+ return _extension;
+ },
+ add(message) {
+ if (isDone) {
+ return;
+ }
+ if (getCurrentPage().length >= gMessagesPerPage) {
+ addPage();
+ }
+ getCurrentPage().push(convertMessage(message, _extension));
+ },
+ done() {
+ if (isDone) {
+ return;
+ }
+ isDone = true;
+ currentPageResolveCallback(getCurrentPage());
+ },
+ hasMorePages() {
+ return readIndex < pages.length;
+ },
+ async getNextPage() {
+ if (readIndex >= pages.length) {
+ return null;
+ }
+ const pageContent = await pagePromises[readIndex];
+ // Increment readIndex only after pagePromise has resolved, so multiple
+ // calls to getNextPage get the same page.
+ readIndex++;
+ return pageContent;
+ },
+ };
+ },
+};
+
+class MessageManager {
+ constructor(extension) {
+ this.extension = extension;
+ }
+
+ convert(msgHdr) {
+ return convertMessage(msgHdr, this.extension);
+ }
+
+ get(id) {
+ return messageTracker.getMessage(id);
+ }
+
+ startMessageList(messageList) {
+ return messageListTracker.startList(messageList, this.extension);
+ }
+}
+
+extensions.on("startup", (type, extension) => {
+ // eslint-disable-line mozilla/balanced-listeners
+ if (extension.hasPermission("accountsRead")) {
+ defineLazyGetter(
+ extension,
+ "folderManager",
+ () => new FolderManager(extension)
+ );
+ }
+ if (extension.hasPermission("addressBooks")) {
+ defineLazyGetter(extension, "addressBookManager", () => {
+ if (!("addressBookCache" in this)) {
+ extensions.loadModule("addressBook");
+ }
+ return {
+ findAddressBookById: this.addressBookCache.findAddressBookById.bind(
+ this.addressBookCache
+ ),
+ findContactById: this.addressBookCache.findContactById.bind(
+ this.addressBookCache
+ ),
+ findMailingListById: this.addressBookCache.findMailingListById.bind(
+ this.addressBookCache
+ ),
+ convert: this.addressBookCache.convert.bind(this.addressBookCache),
+ };
+ });
+ }
+ if (extension.hasPermission("messagesRead")) {
+ defineLazyGetter(
+ extension,
+ "messageManager",
+ () => new MessageManager(extension)
+ );
+ }
+ defineLazyGetter(extension, "tabManager", () => new TabManager(extension));
+ defineLazyGetter(
+ extension,
+ "windowManager",
+ () => new WindowManager(extension)
+ );
+});
diff --git a/comm/mail/components/extensions/parent/ext-mailTabs.js b/comm/mail/components/extensions/parent/ext-mailTabs.js
new file mode 100644
index 0000000000..9cf0bc0844
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-mailTabs.js
@@ -0,0 +1,485 @@
+/* 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/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ QuickFilterManager: "resource:///modules/QuickFilterManager.jsm",
+ MailServices: "resource:///modules/MailServices.jsm",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gDynamicPaneConfig",
+ "mail.pane_config.dynamic",
+ 0
+);
+
+const LAYOUTS = ["standard", "wide", "vertical"];
+// From nsIMsgDBView.idl
+const SORT_TYPE_MAP = new Map(
+ Object.keys(Ci.nsMsgViewSortType).map(key => {
+ // Change "byFoo" to "foo".
+ let shortKey = key[2].toLowerCase() + key.substring(3);
+ return [Ci.nsMsgViewSortType[key], shortKey];
+ })
+);
+const SORT_ORDER_MAP = new Map(
+ Object.keys(Ci.nsMsgViewSortOrder).map(key => [
+ Ci.nsMsgViewSortOrder[key],
+ key,
+ ])
+);
+
+/**
+ * Converts a mail tab to a simple object for use in messages.
+ *
+ * @returns {object}
+ */
+function convertMailTab(tab, context) {
+ let mailTabObject = {
+ id: tab.id,
+ windowId: tab.windowId,
+ active: tab.active,
+ sortType: null,
+ sortOrder: null,
+ viewType: null,
+ layout: LAYOUTS[gDynamicPaneConfig],
+ folderPaneVisible: null,
+ messagePaneVisible: null,
+ };
+
+ let about3Pane = tab.nativeTab.chromeBrowser.contentWindow;
+ let { gViewWrapper, paneLayout } = about3Pane;
+ mailTabObject.folderPaneVisible = paneLayout.folderPaneVisible;
+ mailTabObject.messagePaneVisible = paneLayout.messagePaneVisible;
+ mailTabObject.sortType = SORT_TYPE_MAP.get(gViewWrapper?.primarySortType);
+ mailTabObject.sortOrder = SORT_ORDER_MAP.get(gViewWrapper?.primarySortOrder);
+ if (gViewWrapper?.showGroupedBySort) {
+ mailTabObject.viewType = "groupedBySortType";
+ } else if (gViewWrapper?.showThreaded) {
+ mailTabObject.viewType = "groupedByThread";
+ } else {
+ mailTabObject.viewType = "ungrouped";
+ }
+ if (context.extension.hasPermission("accountsRead")) {
+ mailTabObject.displayedFolder = convertFolder(about3Pane.gFolder);
+ }
+ return mailTabObject;
+}
+
+/**
+ * Listens for changes in the UI to fire events.
+ */
+var uiListener = new (class extends EventEmitter {
+ constructor() {
+ super();
+ this.listenerCount = 0;
+ this.handleEvent = this.handleEvent.bind(this);
+ this.lastSelected = new WeakMap();
+ }
+
+ handleEvent(event) {
+ let browser = event.target.browsingContext.embedderElement;
+ let tabmail = browser.ownerGlobal.top.document.getElementById("tabmail");
+ let nativeTab = tabmail.tabInfo.find(
+ t =>
+ t.chromeBrowser == browser ||
+ t.chromeBrowser == browser.browsingContext.parent.embedderElement
+ );
+
+ if (nativeTab.mode.name != "mail3PaneTab") {
+ return;
+ }
+
+ let tabId = tabTracker.getId(nativeTab);
+ let tab = tabTracker.getTab(tabId);
+
+ if (event.type == "folderURIChanged") {
+ let folderURI = event.detail;
+ let folder = MailServices.folderLookup.getFolderForURL(folderURI);
+ if (this.lastSelected.get(tab) == folder) {
+ return;
+ }
+ this.lastSelected.set(tab, folder);
+ this.emit("folder-changed", tab, folder);
+ } else if (event.type == "messageURIChanged") {
+ let messages =
+ nativeTab.chromeBrowser.contentWindow.gDBView?.getSelectedMsgHdrs();
+ if (messages) {
+ this.emit("messages-changed", tab, messages);
+ }
+ }
+ }
+
+ incrementListeners() {
+ this.listenerCount++;
+ if (this.listenerCount == 1) {
+ windowTracker.addListener("folderURIChanged", this);
+ windowTracker.addListener("messageURIChanged", this);
+ }
+ }
+ decrementListeners() {
+ this.listenerCount--;
+ if (this.listenerCount == 0) {
+ windowTracker.removeListener("folderURIChanged", this);
+ windowTracker.removeListener("messageURIChanged", this);
+ this.lastSelected = new WeakMap();
+ }
+ }
+})();
+
+this.mailTabs = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onDisplayedFolderChanged({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(event, tab, folder) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.sync(tabManager.convert(tab), convertFolder(folder));
+ }
+ uiListener.on("folder-changed", listener);
+ uiListener.incrementListeners();
+ return {
+ unregister: () => {
+ uiListener.off("folder-changed", listener);
+ uiListener.decrementListeners();
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onSelectedMessagesChanged({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ async function listener(event, tab, messages) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ let page = await messageListTracker.startList(messages, extension);
+ fire.sync(tabManager.convert(tab), page);
+ }
+ uiListener.on("messages-changed", listener);
+ uiListener.incrementListeners();
+ return {
+ unregister: () => {
+ uiListener.off("messages-changed", listener);
+ uiListener.decrementListeners();
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ let { extension } = context;
+ let { tabManager } = extension;
+
+ /**
+ * Gets the tab for the given tab id, or the active tab if the id is null.
+ *
+ * @param {?Integer} tabId - The tab id to get
+ * @returns {Tab} The matching tab, or the active tab
+ */
+ async function getTabOrActive(tabId) {
+ let tab;
+ if (tabId) {
+ tab = tabManager.get(tabId);
+ } else {
+ tab = tabManager.wrapTab(tabTracker.activeTab);
+ tabId = tab.id;
+ }
+
+ if (tab && tab.type == "mail") {
+ let windowId = windowTracker.getId(getTabWindow(tab.nativeTab));
+ // Before doing anything with the mail tab, ensure its outer window is
+ // fully loaded.
+ await getNormalWindowReady(context, windowId);
+ return tab;
+ }
+ throw new ExtensionError(`Invalid mail tab ID: ${tabId}`);
+ }
+
+ /**
+ * Set the currently displayed folder in the given tab.
+ *
+ * @param {NativeTabInfo} nativeTabInfo
+ * @param {nsIMsgFolder} folder
+ * @param {boolean} restorePreviousSelection - Select the previously selected
+ * messages of the folder, after it has been set.
+ */
+ async function setFolder(nativeTabInfo, folder, restorePreviousSelection) {
+ let about3Pane = nativeTabInfo.chromeBrowser.contentWindow;
+ if (!nativeTabInfo.folder || nativeTabInfo.folder.URI != folder.URI) {
+ await new Promise(resolve => {
+ let listener = event => {
+ if (event.detail == folder.URI) {
+ about3Pane.removeEventListener("folderURIChanged", listener);
+ resolve();
+ }
+ };
+ about3Pane.addEventListener("folderURIChanged", listener);
+ if (restorePreviousSelection) {
+ about3Pane.restoreState({
+ folderURI: folder.URI,
+ });
+ } else {
+ about3Pane.threadPane.forgetSelection(folder.URI);
+ nativeTabInfo.folder = folder;
+ }
+ });
+ }
+ }
+
+ return {
+ mailTabs: {
+ async query({ active, currentWindow, lastFocusedWindow, windowId }) {
+ await getNormalWindowReady();
+ return Array.from(
+ tabManager.query(
+ {
+ active,
+ currentWindow,
+ lastFocusedWindow,
+ mailTab: true,
+ windowId,
+
+ // All of these are needed for tabManager to return every tab we want.
+ cookieStoreId: null,
+ index: null,
+ screen: null,
+ title: null,
+ url: null,
+ windowType: null,
+ },
+ context
+ ),
+ tab => convertMailTab(tab, context)
+ );
+ },
+
+ async get(tabId) {
+ let tab = await getTabOrActive(tabId);
+ return convertMailTab(tab, context);
+ },
+ async getCurrent() {
+ try {
+ let tab = await getTabOrActive();
+ return convertMailTab(tab, context);
+ } catch (e) {
+ // Do not throw, if the active tab is not a mail tab, but return undefined.
+ return undefined;
+ }
+ },
+
+ async update(tabId, args) {
+ let tab = await getTabOrActive(tabId);
+ let { nativeTab } = tab;
+ let about3Pane = nativeTab.chromeBrowser.contentWindow;
+
+ let {
+ displayedFolder,
+ layout,
+ folderPaneVisible,
+ messagePaneVisible,
+ sortOrder,
+ sortType,
+ viewType,
+ } = args;
+
+ if (displayedFolder) {
+ if (!extension.hasPermission("accountsRead")) {
+ throw new ExtensionError(
+ 'Updating the displayed folder requires the "accountsRead" permission'
+ );
+ }
+
+ let folderUri = folderPathToURI(
+ displayedFolder.accountId,
+ displayedFolder.path
+ );
+ let folder = MailServices.folderLookup.getFolderForURL(folderUri);
+ if (!folder) {
+ throw new ExtensionError(
+ `Folder "${displayedFolder.path}" for account ` +
+ `"${displayedFolder.accountId}" not found.`
+ );
+ }
+ await setFolder(nativeTab, folder, true);
+ }
+
+ if (sortType) {
+ // Change "foo" to "byFoo".
+ sortType = "by" + sortType[0].toUpperCase() + sortType.substring(1);
+ if (
+ sortType in Ci.nsMsgViewSortType &&
+ sortOrder &&
+ sortOrder in Ci.nsMsgViewSortOrder
+ ) {
+ about3Pane.gViewWrapper.sort(
+ Ci.nsMsgViewSortType[sortType],
+ Ci.nsMsgViewSortOrder[sortOrder]
+ );
+ }
+ }
+
+ switch (viewType) {
+ case "groupedBySortType":
+ about3Pane.gViewWrapper.showGroupedBySort = true;
+ break;
+ case "groupedByThread":
+ about3Pane.gViewWrapper.showThreaded = true;
+ break;
+ case "ungrouped":
+ about3Pane.gViewWrapper.showUnthreaded = true;
+ break;
+ }
+
+ // Layout applies to all folder tabs.
+ if (layout) {
+ Services.prefs.setIntPref(
+ "mail.pane_config.dynamic",
+ LAYOUTS.indexOf(layout)
+ );
+ }
+
+ if (typeof folderPaneVisible == "boolean") {
+ about3Pane.paneLayout.folderPaneVisible = folderPaneVisible;
+ }
+ if (typeof messagePaneVisible == "boolean") {
+ about3Pane.paneLayout.messagePaneVisible = messagePaneVisible;
+ }
+ },
+
+ async getSelectedMessages(tabId) {
+ let tab = await getTabOrActive(tabId);
+ let dbView = tab.nativeTab.chromeBrowser.contentWindow?.gDBView;
+ let messageList = dbView ? dbView.getSelectedMsgHdrs() : [];
+ return messageListTracker.startList(messageList, extension);
+ },
+
+ async setSelectedMessages(tabId, messageIds) {
+ if (
+ !extension.hasPermission("messagesRead") ||
+ !extension.hasPermission("accountsRead")
+ ) {
+ throw new ExtensionError(
+ 'Using mailTabs.setSelectedMessages() requires the "accountsRead" and the "messagesRead" permission'
+ );
+ }
+
+ let tab = await getTabOrActive(tabId);
+ let refFolder, refMsgId;
+ let msgHdrs = [];
+ for (let messageId of messageIds) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!refFolder) {
+ refFolder = msgHdr.folder;
+ refMsgId = messageId;
+ }
+ if (msgHdr.folder == refFolder) {
+ msgHdrs.push(msgHdr);
+ } else {
+ throw new ExtensionError(
+ `Message ${refMsgId} and message ${messageId} are not in the same folder, cannot select them both.`
+ );
+ }
+ }
+
+ if (refFolder) {
+ await setFolder(tab.nativeTab, refFolder, false);
+ }
+ let about3Pane = tab.nativeTab.chromeBrowser.contentWindow;
+ const selectedIndices = msgHdrs.map(
+ about3Pane.gViewWrapper.getViewIndexForMsgHdr,
+ about3Pane.gViewWrapper
+ );
+ about3Pane.threadTree.selectedIndices = selectedIndices;
+ if (selectedIndices.length) {
+ about3Pane.threadTree.scrollToIndex(selectedIndices[0], true);
+ }
+ },
+
+ async setQuickFilter(tabId, state) {
+ let tab = await getTabOrActive(tabId);
+ let nativeTab = tab.nativeTab;
+ let about3Pane = nativeTab.chromeBrowser.contentWindow;
+
+ let filterer = about3Pane.quickFilterBar.filterer;
+ filterer.clear();
+
+ // Map of QuickFilter state names to possible WebExtensions state names.
+ let stateMap = {
+ unread: "unread",
+ starred: "flagged",
+ addrBook: "contact",
+ attachment: "attachment",
+ };
+
+ filterer.visible = state.show !== false;
+ for (let [key, name] of Object.entries(stateMap)) {
+ filterer.setFilterValue(key, state[name]);
+ }
+
+ if (state.tags) {
+ filterer.filterValues.tags = {
+ mode: "OR",
+ tags: {},
+ };
+ for (let tag of MailServices.tags.getAllTags()) {
+ filterer.filterValues.tags[tag.key] = null;
+ }
+ if (typeof state.tags == "object") {
+ filterer.filterValues.tags.mode =
+ state.tags.mode == "any" ? "OR" : "AND";
+ for (let [key, value] of Object.entries(state.tags.tags)) {
+ filterer.filterValues.tags.tags[key] = value;
+ }
+ }
+ }
+ if (state.text) {
+ filterer.filterValues.text = {
+ states: {
+ recipients: state.text.recipients || false,
+ sender: state.text.author || false,
+ subject: state.text.subject || false,
+ body: state.text.body || false,
+ },
+ text: state.text.text,
+ };
+ }
+
+ about3Pane.quickFilterBar.updateSearch();
+ },
+
+ onDisplayedFolderChanged: new EventManager({
+ context,
+ module: "mailTabs",
+ event: "onDisplayedFolderChanged",
+ extensionApi: this,
+ }).api(),
+
+ onSelectedMessagesChanged: new EventManager({
+ context,
+ module: "mailTabs",
+ event: "onSelectedMessagesChanged",
+ extensionApi: this,
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-menus.js b/comm/mail/components/extensions/parent/ext-menus.js
new file mode 100644
index 0000000000..0db7ddf809
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-menus.js
@@ -0,0 +1,1544 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailServices",
+ "resource:///modules/MailServices.jsm"
+);
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { SelectionUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/SelectionUtils.sys.mjs"
+);
+
+var { DefaultMap, ExtensionError } = ExtensionUtils;
+
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+var { IconDetails, StartupCache } = ExtensionParent;
+
+var { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+var { makeWidgetId } = ExtensionCommon;
+
+const ACTION_MENU_TOP_LEVEL_LIMIT = 6;
+
+// Map[Extension -> Map[ID -> MenuItem]]
+// Note: we want to enumerate all the menu items so
+// this cannot be a weak map.
+var gMenuMap = new Map();
+
+// Map[Extension -> Map[ID -> MenuCreateProperties]]
+// The map object for each extension is a reference to the same
+// object in StartupCache.menus. This provides a non-async
+// getter for that object.
+var gStartupCache = new Map();
+
+// Map[Extension -> MenuItem]
+var gRootItems = new Map();
+
+// Map[Extension -> ID[]]
+// Menu IDs that were eligible for being shown in the current menu.
+var gShownMenuItems = new DefaultMap(() => []);
+
+// Map[Extension -> Set[Contexts]]
+// A DefaultMap (keyed by extension) which keeps track of the
+// contexts with a subscribed onShown event listener.
+var gOnShownSubscribers = new DefaultMap(() => new Set());
+
+// If id is not specified for an item we use an integer.
+var gNextMenuItemID = 0;
+
+// Used to assign unique names to radio groups.
+var gNextRadioGroupID = 0;
+
+// The max length of a menu item's label.
+var gMaxLabelLength = 64;
+
+var gMenuBuilder = {
+ // When a new menu is opened, this function is called and
+ // we populate the |xulMenu| with all the items from extensions
+ // to be displayed. We always clear all the items again when
+ // popuphidden fires.
+ build(contextData) {
+ contextData = this.maybeOverrideContextData(contextData);
+ let xulMenu = contextData.menu;
+ xulMenu.addEventListener("popuphidden", this);
+ this.xulMenu = xulMenu;
+ for (let [, root] of gRootItems) {
+ this.createAndInsertTopLevelElements(root, contextData, null);
+ }
+ this.afterBuildingMenu(contextData);
+
+ if (
+ contextData.webExtContextData &&
+ !contextData.webExtContextData.showDefaults
+ ) {
+ // Wait until nsContextMenu.js has toggled the visibility of the default
+ // menu items before hiding the default items.
+ Promise.resolve().then(() => this.hideDefaultMenuItems());
+ }
+ },
+
+ maybeOverrideContextData(contextData) {
+ let { webExtContextData } = contextData;
+ if (!webExtContextData || !webExtContextData.overrideContext) {
+ return contextData;
+ }
+ let contextDataBase = {
+ menu: contextData.menu,
+ // eslint-disable-next-line no-use-before-define
+ originalViewType: getContextViewType(contextData),
+ originalViewUrl: contextData.inFrame
+ ? contextData.frameUrl
+ : contextData.pageUrl,
+ webExtContextData,
+ };
+ if (webExtContextData.overrideContext === "tab") {
+ // TODO: Handle invalid tabs more gracefully (instead of throwing).
+ let tab = tabTracker.getTab(webExtContextData.tabId);
+ return {
+ ...contextDataBase,
+ tab,
+ pageUrl: tab.linkedBrowser?.currentURI?.spec,
+ onTab: true,
+ };
+ }
+ throw new ExtensionError(
+ `Unexpected overrideContext: ${webExtContextData.overrideContext}`
+ );
+ },
+
+ createAndInsertTopLevelElements(root, contextData, nextSibling) {
+ const newWebExtensionGroupSeparator = () => {
+ let element =
+ this.xulMenu.ownerDocument.createXULElement("menuseparator");
+ element.classList.add("webextension-group-separator");
+ return element;
+ };
+
+ let rootElements;
+ if (
+ contextData.onAction ||
+ contextData.onBrowserAction ||
+ contextData.onComposeAction ||
+ contextData.onMessageDisplayAction
+ ) {
+ if (contextData.extension.id !== root.extension.id) {
+ return;
+ }
+ rootElements = this.buildTopLevelElements(
+ root,
+ contextData,
+ ACTION_MENU_TOP_LEVEL_LIMIT,
+ false
+ );
+
+ // Action menu items are prepended to the menu, followed by a separator.
+ nextSibling = nextSibling || this.xulMenu.firstElementChild;
+ if (rootElements.length && !this.itemsToCleanUp.has(nextSibling)) {
+ rootElements.push(newWebExtensionGroupSeparator());
+ }
+ } else if (
+ contextData.inActionMenu ||
+ contextData.inBrowserActionMenu ||
+ contextData.inComposeActionMenu ||
+ contextData.inMessageDisplayActionMenu
+ ) {
+ if (contextData.extension.id !== root.extension.id) {
+ return;
+ }
+ rootElements = this.buildTopLevelElements(
+ root,
+ contextData,
+ Infinity,
+ false
+ );
+ } else if (contextData.webExtContextData) {
+ let { extensionId, showDefaults, overrideContext } =
+ contextData.webExtContextData;
+ if (extensionId === root.extension.id) {
+ rootElements = this.buildTopLevelElements(
+ root,
+ contextData,
+ Infinity,
+ false
+ );
+ // The extension menu should be rendered at the top, but after the navigation buttons.
+ nextSibling =
+ nextSibling || this.xulMenu.querySelector(":scope > :first-child");
+ if (
+ rootElements.length &&
+ showDefaults &&
+ !this.itemsToCleanUp.has(nextSibling)
+ ) {
+ rootElements.push(newWebExtensionGroupSeparator());
+ }
+ } else if (!showDefaults && !overrideContext) {
+ // When the default menu items should be hidden, menu items from other
+ // extensions should be hidden too.
+ return;
+ }
+ // Fall through to show default extension menu items.
+ }
+
+ if (!rootElements) {
+ rootElements = this.buildTopLevelElements(root, contextData, 1, true);
+ if (
+ rootElements.length &&
+ !this.itemsToCleanUp.has(this.xulMenu.lastElementChild) &&
+ this.xulMenu.firstChild
+ ) {
+ // All extension menu items are appended at the end.
+ // Prepend separator if this is the first extension menu item.
+ rootElements.unshift(newWebExtensionGroupSeparator());
+ }
+ }
+
+ if (!rootElements.length) {
+ return;
+ }
+
+ if (nextSibling) {
+ nextSibling.before(...rootElements);
+ } else {
+ this.xulMenu.append(...rootElements);
+ }
+ for (let item of rootElements) {
+ this.itemsToCleanUp.add(item);
+ }
+ },
+
+ buildElementWithChildren(item, contextData) {
+ const element = this.buildSingleElement(item, contextData);
+ const children = this.buildChildren(item, contextData);
+ if (children.length) {
+ element.firstElementChild.append(...children);
+ }
+ return element;
+ },
+
+ buildChildren(item, contextData) {
+ let groupName;
+ let children = [];
+ for (let child of item.children) {
+ if (child.type == "radio" && !child.groupName) {
+ if (!groupName) {
+ groupName = `webext-radio-group-${gNextRadioGroupID++}`;
+ }
+ child.groupName = groupName;
+ } else {
+ groupName = null;
+ }
+
+ if (child.enabledForContext(contextData)) {
+ children.push(this.buildElementWithChildren(child, contextData));
+ }
+ }
+ return children;
+ },
+
+ buildTopLevelElements(root, contextData, maxCount, forceManifestIcons) {
+ let children = this.buildChildren(root, contextData);
+
+ // TODO: Fix bug 1492969 and remove this whole if block.
+ if (
+ children.length === 1 &&
+ maxCount === 1 &&
+ forceManifestIcons &&
+ AppConstants.platform === "linux" &&
+ children[0].getAttribute("type") === "checkbox"
+ ) {
+ // Keep single checkbox items in the submenu on Linux since
+ // the extension icon overlaps the checkbox otherwise.
+ maxCount = 0;
+ }
+
+ if (children.length > maxCount) {
+ // Move excess items into submenu.
+ let rootElement = this.buildSingleElement(root, contextData);
+ rootElement.setAttribute("ext-type", "top-level-menu");
+ rootElement.firstElementChild.append(...children.splice(maxCount - 1));
+ children.push(rootElement);
+ }
+
+ if (forceManifestIcons) {
+ for (let rootElement of children) {
+ // Display the extension icon on the root element.
+ if (
+ root.extension.manifest.icons &&
+ rootElement.getAttribute("type") !== "checkbox"
+ ) {
+ this.setMenuItemIcon(
+ rootElement,
+ root.extension,
+ contextData,
+ root.extension.manifest.icons
+ );
+ } else {
+ this.removeMenuItemIcon(rootElement);
+ }
+ }
+ }
+ return children;
+ },
+
+ removeSeparatorIfNoTopLevelItems() {
+ // Extension menu items always have have a non-empty ID.
+ let isNonExtensionSeparator = item =>
+ item.nodeName === "menuseparator" && !item.id;
+
+ // itemsToCleanUp contains all top-level menu items. A separator should
+ // only be kept if it is next to an extension menu item.
+ let isExtensionMenuItemSibling = item =>
+ item && this.itemsToCleanUp.has(item) && !isNonExtensionSeparator(item);
+
+ for (let item of this.itemsToCleanUp) {
+ if (isNonExtensionSeparator(item)) {
+ if (
+ !isExtensionMenuItemSibling(item.previousElementSibling) &&
+ !isExtensionMenuItemSibling(item.nextElementSibling)
+ ) {
+ item.remove();
+ this.itemsToCleanUp.delete(item);
+ }
+ }
+ }
+ },
+
+ buildSingleElement(item, contextData) {
+ let doc = contextData.menu.ownerDocument;
+ let element;
+ if (item.children.length) {
+ element = this.createMenuElement(doc, item);
+ } else if (item.type == "separator") {
+ element = doc.createXULElement("menuseparator");
+ } else {
+ element = doc.createXULElement("menuitem");
+ }
+
+ return this.customizeElement(element, item, contextData);
+ },
+
+ createMenuElement(doc, item) {
+ let element = doc.createXULElement("menu");
+ // Menu elements need to have a menupopup child for its menu items.
+ let menupopup = doc.createXULElement("menupopup");
+ element.appendChild(menupopup);
+ return element;
+ },
+
+ customizeElement(element, item, contextData) {
+ let label = item.title;
+ if (label) {
+ let accessKey;
+ label = label.replace(/&([\S\s]|$)/g, (_, nextChar, i) => {
+ if (nextChar === "&") {
+ return "&";
+ }
+ if (accessKey === undefined) {
+ if (nextChar === "%" && label.charAt(i + 2) === "s") {
+ accessKey = "";
+ } else {
+ accessKey = nextChar;
+ }
+ }
+ return nextChar;
+ });
+ element.setAttribute("accesskey", accessKey || "");
+
+ if (contextData.isTextSelected && label.includes("%s")) {
+ let selection = contextData.selectionText.trim();
+ // The rendering engine will truncate the title if it's longer than 64 characters.
+ // But if it makes sense let's try truncate selection text only, to handle cases like
+ // 'look up "%s" in MyDictionary' more elegantly.
+
+ let codePointsToRemove = 0;
+
+ let selectionArray = Array.from(selection);
+
+ let completeLabelLength = label.length - 2 + selectionArray.length;
+ if (completeLabelLength > gMaxLabelLength) {
+ codePointsToRemove = completeLabelLength - gMaxLabelLength;
+ }
+
+ if (codePointsToRemove) {
+ let ellipsis = "\u2026";
+ try {
+ ellipsis = Services.prefs.getComplexValue(
+ "intl.ellipsis",
+ Ci.nsIPrefLocalizedString
+ ).data;
+ } catch (e) {}
+ codePointsToRemove += 1;
+ selection =
+ selectionArray.slice(0, -codePointsToRemove).join("") + ellipsis;
+ }
+
+ label = label.replace(/%s/g, selection);
+ }
+
+ element.setAttribute("label", label);
+ }
+
+ element.setAttribute("id", item.elementId);
+
+ if ("icons" in item) {
+ if (item.icons) {
+ this.setMenuItemIcon(element, item.extension, contextData, item.icons);
+ } else {
+ this.removeMenuItemIcon(element);
+ }
+ }
+
+ if (item.type == "checkbox") {
+ element.setAttribute("type", "checkbox");
+ if (item.checked) {
+ element.setAttribute("checked", "true");
+ }
+ } else if (item.type == "radio") {
+ element.setAttribute("type", "radio");
+ element.setAttribute("name", item.groupName);
+ if (item.checked) {
+ element.setAttribute("checked", "true");
+ }
+ }
+
+ if (!item.enabled) {
+ element.setAttribute("disabled", "true");
+ }
+
+ let button;
+
+ element.addEventListener(
+ "command",
+ async event => {
+ if (event.target !== event.currentTarget) {
+ return;
+ }
+ const wasChecked = item.checked;
+ if (item.type == "checkbox") {
+ item.checked = !item.checked;
+ } else if (item.type == "radio") {
+ // Deselect all radio items in the current radio group.
+ for (let child of item.parent.children) {
+ if (child.type == "radio" && child.groupName == item.groupName) {
+ child.checked = false;
+ }
+ }
+ // Select the clicked radio item.
+ item.checked = true;
+ }
+
+ let { webExtContextData } = contextData;
+ if (
+ contextData.tab &&
+ // If the menu context was overridden by the extension, do not grant
+ // activeTab since the extension also controls the tabId.
+ (!webExtContextData ||
+ webExtContextData.extensionId !== item.extension.id)
+ ) {
+ item.tabManager.addActiveTabPermission(contextData.tab);
+ }
+
+ let info = await item.getClickInfo(contextData, wasChecked);
+ info.modifiers = clickModifiersFromEvent(event);
+
+ info.button = button;
+ let _execute_action =
+ item.extension.manifestVersion < 3
+ ? "_execute_browser_action"
+ : "_execute_action";
+
+ // Allow menus to open various actions supported in webext prior
+ // to notifying onclicked.
+ let actionFor = {
+ [_execute_action]: global.browserActionFor,
+ _execute_compose_action: global.composeActionFor,
+ _execute_message_display_action: global.messageDisplayActionFor,
+ }[item.command];
+ if (actionFor) {
+ let win = event.target.ownerGlobal;
+ actionFor(item.extension).triggerAction(win);
+ return;
+ }
+
+ item.extension.emit(
+ "webext-menu-menuitem-click",
+ info,
+ contextData.tab
+ );
+ },
+ { once: true }
+ );
+
+ // eslint-disable-next-line mozilla/balanced-listeners
+ element.addEventListener("click", event => {
+ if (
+ event.target !== event.currentTarget ||
+ // Ignore menu items that are usually not clickeable,
+ // such as separators and parents of submenus and disabled items.
+ element.localName !== "menuitem" ||
+ element.disabled
+ ) {
+ return;
+ }
+
+ button = event.button;
+ if (event.button) {
+ element.doCommand();
+ contextData.menu.hidePopup();
+ }
+ });
+
+ // Don't publish the ID of the root because the root element is
+ // auto-generated.
+ if (item.parent) {
+ gShownMenuItems.get(item.extension).push(item.id);
+ }
+
+ return element;
+ },
+
+ setMenuItemIcon(element, extension, contextData, icons) {
+ let parentWindow = contextData.menu.ownerGlobal;
+
+ let { icon } = IconDetails.getPreferredIcon(
+ icons,
+ extension,
+ 16 * parentWindow.devicePixelRatio
+ );
+
+ // The extension icons in the manifest are not pre-resolved, since
+ // they're sometimes used by the add-on manager when the extension is
+ // not enabled, and its URLs are not resolvable.
+ let resolvedURL = extension.baseURI.resolve(icon);
+
+ if (element.localName == "menu") {
+ element.setAttribute("class", "menu-iconic");
+ } else if (element.localName == "menuitem") {
+ element.setAttribute("class", "menuitem-iconic");
+ }
+
+ element.setAttribute("image", resolvedURL);
+ },
+
+ // Undo changes from setMenuItemIcon.
+ removeMenuItemIcon(element) {
+ element.removeAttribute("class");
+ element.removeAttribute("image");
+ },
+
+ rebuildMenu(extension) {
+ let { contextData } = this;
+ if (!contextData) {
+ // This happens if the menu is not visible.
+ return;
+ }
+
+ // Find the group of existing top-level items (usually 0 or 1 items)
+ // and remember its position for when the new items are inserted.
+ let elementIdPrefix = `${makeWidgetId(extension.id)}-menuitem-`;
+ let nextSibling = null;
+ for (let item of this.itemsToCleanUp) {
+ if (item.id && item.id.startsWith(elementIdPrefix)) {
+ nextSibling = item.nextSibling;
+ item.remove();
+ this.itemsToCleanUp.delete(item);
+ }
+ }
+
+ let root = gRootItems.get(extension);
+ if (root) {
+ this.createAndInsertTopLevelElements(root, contextData, nextSibling);
+ }
+ this.removeSeparatorIfNoTopLevelItems();
+ },
+
+ // This should be called once, after constructing the top-level menus, if any.
+ afterBuildingMenu(contextData) {
+ function dispatchOnShownEvent(extension) {
+ // Note: gShownMenuItems is a DefaultMap, so .get(extension) causes the
+ // extension to be stored in the map even if there are currently no
+ // shown menu items. This ensures that the onHidden event can be fired
+ // when the menu is closed.
+ let menuIds = gShownMenuItems.get(extension);
+ extension.emit("webext-menu-shown", menuIds, contextData);
+ }
+
+ if (
+ contextData.onAction ||
+ contextData.onBrowserAction ||
+ contextData.onComposeAction ||
+ contextData.onMessageDisplayAction
+ ) {
+ dispatchOnShownEvent(contextData.extension);
+ } else {
+ for (const extension of gOnShownSubscribers.keys()) {
+ dispatchOnShownEvent(extension);
+ }
+ }
+
+ this.contextData = contextData;
+ },
+
+ hideDefaultMenuItems() {
+ for (let item of this.xulMenu.children) {
+ if (!this.itemsToCleanUp.has(item)) {
+ item.hidden = true;
+ }
+ }
+ },
+
+ handleEvent(event) {
+ if (this.xulMenu != event.target || event.type != "popuphidden") {
+ return;
+ }
+
+ delete this.xulMenu;
+ delete this.contextData;
+
+ let target = event.target;
+ target.removeEventListener("popuphidden", this);
+ for (let item of this.itemsToCleanUp) {
+ item.remove();
+ }
+ this.itemsToCleanUp.clear();
+ for (let extension of gShownMenuItems.keys()) {
+ extension.emit("webext-menu-hidden");
+ }
+ gShownMenuItems.clear();
+ },
+
+ itemsToCleanUp: new Set(),
+};
+
+// Called from different action popups.
+global.actionContextMenu = function (contextData) {
+ contextData.originalViewType = "tab";
+ gMenuBuilder.build(contextData);
+};
+
+const contextsMap = {
+ onAudio: "audio",
+ onEditable: "editable",
+ inFrame: "frame",
+ onImage: "image",
+ onLink: "link",
+ onPassword: "password",
+ isTextSelected: "selection",
+ onVideo: "video",
+
+ onAction: "action",
+ onBrowserAction: "browser_action",
+ onComposeAction: "compose_action",
+ onMessageDisplayAction: "message_display_action",
+ inActionMenu: "action_menu",
+ inBrowserActionMenu: "browser_action_menu",
+ inComposeActionMenu: "compose_action_menu",
+ inMessageDisplayActionMenu: "message_display_action_menu",
+
+ onComposeBody: "compose_body",
+ onTab: "tab",
+ inToolsMenu: "tools_menu",
+ selectedMessages: "message_list",
+ selectedFolder: "folder_pane",
+ selectedComposeAttachments: "compose_attachments",
+ selectedMessageAttachments: "message_attachments",
+ allMessageAttachments: "all_message_attachments",
+};
+
+const chromeElementsMap = {
+ msgSubject: "composeSubject",
+ toAddrInput: "composeTo",
+ ccAddrInput: "composeCc",
+ bccAddrInput: "composeBcc",
+ replyAddrInput: "composeReplyTo",
+ newsgroupsAddrInput: "composeNewsgroupTo",
+ followupAddrInput: "composeFollowupTo",
+};
+
+const getMenuContexts = contextData => {
+ let contexts = new Set();
+
+ for (const [key, value] of Object.entries(contextsMap)) {
+ if (contextData[key]) {
+ contexts.add(value);
+ }
+ }
+
+ if (contexts.size === 0) {
+ contexts.add("page");
+ }
+
+ // New non-content contexts supported in Thunderbird are not part of "all".
+ if (!contextData.onTab && !contextData.inToolsMenu) {
+ contexts.add("all");
+ }
+
+ return contexts;
+};
+
+function getContextViewType(contextData) {
+ if ("originalViewType" in contextData) {
+ return contextData.originalViewType;
+ }
+ if (
+ contextData.webExtBrowserType === "popup" ||
+ contextData.webExtBrowserType === "sidebar"
+ ) {
+ return contextData.webExtBrowserType;
+ }
+ if (contextData.tab && contextData.menu.id === "browserContext") {
+ return "tab";
+ }
+ return undefined;
+}
+
+async function addMenuEventInfo(
+ info,
+ contextData,
+ extension,
+ includeSensitiveData
+) {
+ info.viewType = getContextViewType(contextData);
+ if (contextData.onVideo) {
+ info.mediaType = "video";
+ } else if (contextData.onAudio) {
+ info.mediaType = "audio";
+ } else if (contextData.onImage) {
+ info.mediaType = "image";
+ }
+ if (contextData.frameId !== undefined) {
+ info.frameId = contextData.frameId;
+ }
+ info.editable = contextData.onEditable || false;
+ if (includeSensitiveData) {
+ if (contextData.timeStamp) {
+ // Convert to integer, in case the DOMHighResTimeStamp has a fractional part.
+ info.targetElementId = Math.floor(contextData.timeStamp);
+ }
+ if (contextData.onLink) {
+ info.linkText = contextData.linkText;
+ info.linkUrl = contextData.linkUrl;
+ }
+ if (contextData.onAudio || contextData.onImage || contextData.onVideo) {
+ info.srcUrl = contextData.srcUrl;
+ }
+ info.pageUrl = contextData.pageUrl;
+ if (contextData.inFrame) {
+ info.frameUrl = contextData.frameUrl;
+ }
+ if (contextData.isTextSelected) {
+ info.selectionText = contextData.selectionText;
+ }
+ }
+ // If the context was overridden, then frameUrl should be the URL of the
+ // document in which the menu was opened (instead of undefined, even if that
+ // document is not in a frame).
+ if (contextData.originalViewUrl) {
+ info.frameUrl = contextData.originalViewUrl;
+ }
+
+ if (contextData.fieldId) {
+ info.fieldId = contextData.fieldId;
+ }
+
+ if (contextData.selectedMessages && extension.hasPermission("messagesRead")) {
+ info.selectedMessages = await messageListTracker.startList(
+ contextData.selectedMessages,
+ extension
+ );
+ }
+ if (extension.hasPermission("accountsRead")) {
+ for (let folderType of ["displayedFolder", "selectedFolder"]) {
+ if (contextData[folderType]) {
+ let folder = convertFolder(contextData[folderType]);
+ // If the context menu click in the folder pane occurred on a root folder
+ // representing an account, do not include a selectedFolder object, but
+ // the corresponding selectedAccount object.
+ if (folderType == "selectedFolder" && folder.path == "/") {
+ info.selectedAccount = convertAccount(
+ MailServices.accounts.getAccount(folder.accountId)
+ );
+ } else {
+ info[folderType] = traverseSubfolders(
+ contextData[folderType],
+ folder.accountId
+ );
+ }
+ }
+ }
+ }
+ if (
+ (contextData.selectedMessageAttachments ||
+ contextData.allMessageAttachments) &&
+ extension.hasPermission("messagesRead")
+ ) {
+ let attachments =
+ contextData.selectedMessageAttachments ||
+ contextData.allMessageAttachments;
+ info.attachments = attachments.map(attachment => {
+ return {
+ contentType: attachment.contentType,
+ name: attachment.name,
+ size: attachment.size,
+ partName: attachment.partID,
+ };
+ });
+ }
+ if (
+ contextData.selectedComposeAttachments &&
+ extension.hasPermission("compose")
+ ) {
+ if (!("composeAttachmentTracker" in global)) {
+ extensions.loadModule("compose");
+ }
+
+ info.attachments = contextData.selectedComposeAttachments.map(a =>
+ global.composeAttachmentTracker.convert(a, contextData.menu.ownerGlobal)
+ );
+ }
+}
+
+class MenuItem {
+ constructor(extension, createProperties, isRoot = false) {
+ this.extension = extension;
+ this.children = [];
+ this.parent = null;
+ this.tabManager = extension.tabManager;
+
+ this.setDefaults();
+ this.setProps(createProperties);
+
+ if (!this.hasOwnProperty("_id")) {
+ this.id = gNextMenuItemID++;
+ }
+ // If the item is not the root and has no parent
+ // it must be a child of the root.
+ if (!isRoot && !this.parent) {
+ this.root.addChild(this);
+ }
+ }
+
+ static mergeProps(obj, properties) {
+ for (let propName in properties) {
+ if (properties[propName] === null) {
+ // Omitted optional argument.
+ continue;
+ }
+ obj[propName] = properties[propName];
+ }
+
+ if ("icons" in properties) {
+ if (properties.icons === null) {
+ obj.icons = null;
+ } else if (typeof properties.icons == "string") {
+ obj.icons = { 16: properties.icons };
+ }
+ }
+ }
+
+ setProps(createProperties) {
+ MenuItem.mergeProps(this, createProperties);
+
+ if (createProperties.documentUrlPatterns != null) {
+ this.documentUrlMatchPattern = new MatchPatternSet(
+ this.documentUrlPatterns,
+ {
+ restrictSchemes: this.extension.restrictSchemes,
+ }
+ );
+ }
+
+ if (createProperties.targetUrlPatterns != null) {
+ this.targetUrlMatchPattern = new MatchPatternSet(this.targetUrlPatterns, {
+ // restrictSchemes default to false when matching links instead of pages
+ // (see Bug 1280370 for a rationale).
+ restrictSchemes: false,
+ });
+ }
+
+ // If a child MenuItem does not specify any contexts, then it should
+ // inherit the contexts specified from its parent.
+ if (createProperties.parentId && !createProperties.contexts) {
+ this.contexts = this.parent.contexts;
+ }
+ }
+
+ setDefaults() {
+ this.setProps({
+ type: "normal",
+ checked: false,
+ contexts: ["all"],
+ enabled: true,
+ visible: true,
+ });
+ }
+
+ set id(id) {
+ if (this.hasOwnProperty("_id")) {
+ throw new ExtensionError("ID of a MenuItem cannot be changed");
+ }
+ let isIdUsed = gMenuMap.get(this.extension).has(id);
+ if (isIdUsed) {
+ throw new ExtensionError(`ID already exists: ${id}`);
+ }
+ this._id = id;
+ }
+
+ get id() {
+ return this._id;
+ }
+
+ get elementId() {
+ let id = this.id;
+ // If the ID is an integer, it is auto-generated and globally unique.
+ // If the ID is a string, it is only unique within one extension and the
+ // ID needs to be concatenated with the extension ID.
+ if (typeof id !== "number") {
+ // To avoid collisions with numeric IDs, add a prefix to string IDs.
+ id = `_${id}`;
+ }
+ return `${makeWidgetId(this.extension.id)}-menuitem-${id}`;
+ }
+
+ ensureValidParentId(parentId) {
+ if (parentId === undefined) {
+ return;
+ }
+ let menuMap = gMenuMap.get(this.extension);
+ if (!menuMap.has(parentId)) {
+ throw new ExtensionError(
+ `Could not find any MenuItem with id: ${parentId}`
+ );
+ }
+ for (let item = menuMap.get(parentId); item; item = item.parent) {
+ if (item === this) {
+ throw new ExtensionError(
+ "MenuItem cannot be an ancestor (or self) of its new parent."
+ );
+ }
+ }
+ }
+
+ /**
+ * When updating menu properties we need to ensure parents exist
+ * in the cache map before children. That allows the menus to be
+ * created in the correct sequence on startup. This reparents the
+ * tree starting from this instance of MenuItem.
+ */
+ reparentInCache() {
+ let { id, extension } = this;
+ let cachedMap = gStartupCache.get(extension);
+ let createProperties = cachedMap.get(id);
+ cachedMap.delete(id);
+ cachedMap.set(id, createProperties);
+
+ for (let child of this.children) {
+ child.reparentInCache();
+ }
+ }
+
+ set parentId(parentId) {
+ this.ensureValidParentId(parentId);
+
+ if (this.parent) {
+ this.parent.detachChild(this);
+ }
+
+ if (parentId === undefined) {
+ this.root.addChild(this);
+ } else {
+ let menuMap = gMenuMap.get(this.extension);
+ menuMap.get(parentId).addChild(this);
+ }
+ }
+
+ get parentId() {
+ return this.parent ? this.parent.id : undefined;
+ }
+
+ addChild(child) {
+ if (child.parent) {
+ throw new ExtensionError("Child MenuItem already has a parent.");
+ }
+ this.children.push(child);
+ child.parent = this;
+ }
+
+ detachChild(child) {
+ let idx = this.children.indexOf(child);
+ if (idx < 0) {
+ throw new ExtensionError(
+ "Child MenuItem not found, it cannot be removed."
+ );
+ }
+ this.children.splice(idx, 1);
+ child.parent = null;
+ }
+
+ get root() {
+ let extension = this.extension;
+ if (!gRootItems.has(extension)) {
+ let root = new MenuItem(
+ extension,
+ { title: extension.name },
+ /* isRoot = */ true
+ );
+ gRootItems.set(extension, root);
+ }
+
+ return gRootItems.get(extension);
+ }
+
+ remove() {
+ if (this.parent) {
+ this.parent.detachChild(this);
+ }
+ let children = this.children.slice(0);
+ for (let child of children) {
+ child.remove();
+ }
+
+ let menuMap = gMenuMap.get(this.extension);
+ menuMap.delete(this.id);
+ // Menu items are saved if !extension.persistentBackground.
+ if (gStartupCache.get(this.extension)?.delete(this.id)) {
+ StartupCache.save();
+ }
+ if (this.root == this) {
+ gRootItems.delete(this.extension);
+ }
+ }
+
+ async getClickInfo(contextData, wasChecked) {
+ let info = {
+ menuItemId: this.id,
+ };
+ if (this.parent) {
+ info.parentMenuItemId = this.parentId;
+ }
+
+ await addMenuEventInfo(info, contextData, this.extension, true);
+
+ if (this.type === "checkbox" || this.type === "radio") {
+ info.checked = this.checked;
+ info.wasChecked = wasChecked;
+ }
+
+ return info;
+ }
+
+ enabledForContext(contextData) {
+ if (!this.visible) {
+ return false;
+ }
+ let contexts = getMenuContexts(contextData);
+ if (!this.contexts.some(n => contexts.has(n))) {
+ return false;
+ }
+
+ if (
+ this.viewTypes &&
+ !this.viewTypes.includes(getContextViewType(contextData))
+ ) {
+ return false;
+ }
+
+ let docPattern = this.documentUrlMatchPattern;
+ // When viewTypes is specified, the menu item is expected to be restricted
+ // to documents. So let documentUrlPatterns always apply to the URL of the
+ // document in which the menu was opened. When maybeOverrideContextData
+ // changes the context, contextData.pageUrl does not reflect that URL any
+ // more, so use contextData.originalViewUrl instead.
+ if (docPattern && this.viewTypes && contextData.originalViewUrl) {
+ if (
+ !docPattern.matches(Services.io.newURI(contextData.originalViewUrl))
+ ) {
+ return false;
+ }
+ docPattern = null; // Null it so that it won't be used with pageURI below.
+ }
+
+ let pageURI = contextData[contextData.inFrame ? "frameUrl" : "pageUrl"];
+ if (pageURI) {
+ pageURI = Services.io.newURI(pageURI);
+ if (docPattern && !docPattern.matches(pageURI)) {
+ return false;
+ }
+ }
+
+ let targetPattern = this.targetUrlMatchPattern;
+ if (targetPattern) {
+ let targetUrls = [];
+ if (contextData.onImage || contextData.onAudio || contextData.onVideo) {
+ // TODO: Double check if srcUrl is always set when we need it.
+ targetUrls.push(contextData.srcUrl);
+ }
+ if (contextData.onLink) {
+ targetUrls.push(contextData.linkUrl);
+ }
+ if (
+ !targetUrls.some(targetUrl =>
+ targetPattern.matches(Services.io.newURI(targetUrl))
+ )
+ ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
+
+// While any extensions are active, this Tracker registers to observe/listen
+// for menu events from both Tools and context menus, both content and chrome.
+const menuTracker = {
+ menuIds: [
+ "tabContextMenu",
+ "folderPaneContext",
+ "msgComposeAttachmentItemContext",
+ "taskPopup",
+ ],
+
+ register() {
+ Services.obs.addObserver(this, "on-build-contextmenu");
+ for (const window of windowTracker.browserWindows()) {
+ this.onWindowOpen(window);
+ }
+ windowTracker.addOpenListener(this.onWindowOpen);
+ },
+
+ unregister() {
+ Services.obs.removeObserver(this, "on-build-contextmenu");
+ for (const window of windowTracker.browserWindows()) {
+ this.cleanupWindow(window);
+ }
+ windowTracker.removeOpenListener(this.onWindowOpen);
+ },
+
+ observe(subject, topic, data) {
+ subject = subject.wrappedJSObject;
+ gMenuBuilder.build(subject);
+ },
+
+ onWindowOpen(window) {
+ // Register the event listener on the window, as some menus we are
+ // interested in are dynamically created:
+ // https://hg.mozilla.org/mozilla-central/file/83a21ab93aff939d348468e69249a3a33ccfca88/toolkit/content/editMenuOverlay.js#l96
+ window.addEventListener("popupshowing", menuTracker);
+ },
+
+ cleanupWindow(window) {
+ window.removeEventListener("popupshowing", this);
+ },
+
+ handleEvent(event) {
+ const menu = event.target;
+ const trigger = menu.triggerNode;
+ const win = menu.ownerGlobal;
+ switch (menu.id) {
+ case "taskPopup": {
+ let info = { menu, inToolsMenu: true };
+ if (
+ win.document.location.href ==
+ "chrome://messenger/content/messenger.xhtml"
+ ) {
+ info.tab = tabTracker.activeTab;
+ // Calendar and Task view do not have a browser/URL.
+ info.pageUrl = info.tab.linkedBrowser?.currentURI?.spec;
+ } else {
+ info.tab = win;
+ }
+ gMenuBuilder.build(info);
+ break;
+ }
+ case "tabContextMenu": {
+ let triggerTab = trigger.closest("tab");
+ const tab = triggerTab || tabTracker.activeTab;
+ const pageUrl = tab.linkedBrowser?.currentURI?.spec;
+ gMenuBuilder.build({ menu, tab, pageUrl, onTab: true });
+ break;
+ }
+ case "folderPaneContext": {
+ const tab = tabTracker.activeTab;
+ const pageUrl = tab.linkedBrowser?.currentURI?.spec;
+ gMenuBuilder.build({
+ menu,
+ tab,
+ pageUrl,
+ selectedFolder: win.folderPaneContextMenu.activeFolder,
+ });
+ break;
+ }
+ case "attachmentListContext": {
+ let attachmentList =
+ menu.ownerGlobal.document.getElementById("attachmentList");
+ let allMessageAttachments = [...attachmentList.children].map(
+ item => item.attachment
+ );
+ gMenuBuilder.build({
+ menu,
+ tab: menu.ownerGlobal,
+ allMessageAttachments,
+ });
+ break;
+ }
+ case "attachmentItemContext": {
+ let attachmentList =
+ menu.ownerGlobal.document.getElementById("attachmentList");
+ let attachmentInfo =
+ menu.ownerGlobal.document.getElementById("attachmentInfo");
+
+ // If we opened the context menu from the attachment info area (the paperclip,
+ // "1 attachment" label, filename, or file size, just grab the first (and
+ // only) attachment as our "selected" attachments.
+ let selectedMessageAttachments;
+ if (
+ menu.triggerNode == attachmentInfo ||
+ menu.triggerNode.parentNode == attachmentInfo
+ ) {
+ selectedMessageAttachments = [
+ attachmentList.getItemAtIndex(0).attachment,
+ ];
+ } else {
+ selectedMessageAttachments = [...attachmentList.selectedItems].map(
+ item => item.attachment
+ );
+ }
+
+ gMenuBuilder.build({
+ menu,
+ tab: menu.ownerGlobal,
+ selectedMessageAttachments,
+ });
+ break;
+ }
+ case "msgComposeAttachmentItemContext": {
+ let bucket = menu.ownerDocument.getElementById("attachmentBucket");
+ let selectedComposeAttachments = [];
+ for (let item of bucket.itemChildren) {
+ if (item.selected) {
+ selectedComposeAttachments.push(item.attachment);
+ }
+ }
+ gMenuBuilder.build({
+ menu,
+ tab: menu.ownerGlobal,
+ selectedComposeAttachments,
+ });
+ break;
+ }
+ default:
+ // Fall back to the triggerNode. Make sure we are not re-triggered by a
+ // sub-menu.
+ if (menu.parentNode.localName == "menu") {
+ return;
+ }
+ if (Object.keys(chromeElementsMap).includes(trigger?.id)) {
+ let selectionInfo = SelectionUtils.getSelectionDetails(win);
+ let isContentSelected = !selectionInfo.docSelectionIsCollapsed;
+ let textSelected = selectionInfo.text;
+ let isTextSelected = !!textSelected.length;
+ gMenuBuilder.build({
+ menu,
+ tab: win,
+ pageUrl: win.browser.currentURI.spec,
+ onEditable: true,
+ isContentSelected,
+ isTextSelected,
+ onTextInput: true,
+ originalViewType: "tab",
+ fieldId: chromeElementsMap[trigger.id],
+ selectionText: isTextSelected ? selectionInfo.fullText : undefined,
+ });
+ }
+ break;
+ }
+ },
+};
+
+this.menus = class extends ExtensionAPIPersistent {
+ constructor(extension) {
+ super(extension);
+
+ if (!gMenuMap.size) {
+ menuTracker.register();
+ }
+ gMenuMap.set(extension, new Map());
+ }
+
+ restoreFromCache() {
+ let { extension } = this;
+ // ensure extension has not shutdown
+ if (!this.extension) {
+ return;
+ }
+ for (let createProperties of gStartupCache.get(extension).values()) {
+ // The order of menu creation is significant, see reparentInCache.
+ let menuItem = new MenuItem(extension, createProperties);
+ gMenuMap.get(extension).set(menuItem.id, menuItem);
+ }
+ // Used for testing
+ extension.emit("webext-menus-created", gMenuMap.get(extension));
+ }
+
+ async onStartup() {
+ let { extension } = this;
+ if (extension.persistentBackground) {
+ return;
+ }
+ // Using the map retains insertion order.
+ let cachedMenus = await StartupCache.menus.get(extension.id, () => {
+ return new Map();
+ });
+ gStartupCache.set(extension, cachedMenus);
+ if (!cachedMenus.size) {
+ return;
+ }
+
+ this.restoreFromCache();
+ }
+
+ onShutdown() {
+ let { extension } = this;
+
+ if (gMenuMap.has(extension)) {
+ gMenuMap.delete(extension);
+ gRootItems.delete(extension);
+ gShownMenuItems.delete(extension);
+ gStartupCache.delete(extension);
+ gOnShownSubscribers.delete(extension);
+ if (!gMenuMap.size) {
+ menuTracker.unregister();
+ }
+ }
+ }
+
+ PERSISTENT_EVENTS = {
+ onShown({ fire }) {
+ let { extension } = this;
+ let listener = async (event, menuIds, contextData) => {
+ let info = {
+ menuIds,
+ contexts: Array.from(getMenuContexts(contextData)),
+ };
+
+ let nativeTab = contextData.tab;
+
+ // The menus.onShown event is fired before the user has consciously
+ // interacted with an extension, so we require permissions before
+ // exposing sensitive contextual data.
+ let contextUrl = contextData.inFrame
+ ? contextData.frameUrl
+ : contextData.pageUrl;
+
+ let ownerDocumentUrl = contextData.menu.ownerDocument.location.href;
+
+ let contextScheme;
+ if (contextUrl) {
+ contextScheme = Services.io.newURI(contextUrl).scheme;
+ }
+
+ let includeSensitiveData =
+ (nativeTab &&
+ extension.tabManager.hasActiveTabPermission(nativeTab)) ||
+ (contextUrl && extension.allowedOrigins.matches(contextUrl)) ||
+ (MESSAGE_PROTOCOLS.includes(contextScheme) &&
+ extension.hasPermission("messagesRead")) ||
+ (ownerDocumentUrl ==
+ "chrome://messenger/content/messengercompose/messengercompose.xhtml" &&
+ extension.hasPermission("compose"));
+
+ await addMenuEventInfo(
+ info,
+ contextData,
+ extension,
+ includeSensitiveData
+ );
+
+ let tab = nativeTab && extension.tabManager.convert(nativeTab);
+ fire.sync(info, tab);
+ };
+ gOnShownSubscribers.get(extension).add(listener);
+ extension.on("webext-menu-shown", listener);
+ return {
+ unregister() {
+ const listeners = gOnShownSubscribers.get(extension);
+ listeners.delete(listener);
+ if (listeners.size === 0) {
+ gOnShownSubscribers.delete(extension);
+ }
+ extension.off("webext-menu-shown", listener);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ onHidden({ fire }) {
+ let { extension } = this;
+ let listener = () => {
+ fire.sync();
+ };
+ extension.on("webext-menu-hidden", listener);
+ return {
+ unregister() {
+ extension.off("webext-menu-hidden", listener);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ onClicked({ context, fire }) {
+ let { extension } = this;
+ let listener = async (event, info, nativeTab) => {
+ let { linkedBrowser } = nativeTab || tabTracker.activeTab;
+ let tab = nativeTab && extension.tabManager.convert(nativeTab);
+ if (fire.wakeup) {
+ // force the wakeup, thus the call to convert to get the context.
+ await fire.wakeup();
+ // If while waiting the tab disappeared we bail out.
+ if (
+ !linkedBrowser.ownerGlobal.gBrowser.getTabForBrowser(linkedBrowser)
+ ) {
+ console.error(
+ `menus.onClicked: target tab closed during background startup.`
+ );
+ return;
+ }
+ }
+ context.withPendingBrowser(linkedBrowser, () => fire.sync(info, tab));
+ };
+
+ extension.on("webext-menu-menuitem-click", listener);
+ return {
+ unregister() {
+ extension.off("webext-menu-menuitem-click", listener);
+ },
+ convert(_fire, _context) {
+ fire = _fire;
+ context = _context;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ let { extension } = context;
+
+ return {
+ menus: {
+ refresh() {
+ gMenuBuilder.rebuildMenu(extension);
+ },
+
+ onShown: new EventManager({
+ context,
+ module: "menus",
+ event: "onShown",
+ extensionApi: this,
+ }).api(),
+ onHidden: new EventManager({
+ context,
+ module: "menus",
+ event: "onHidden",
+ extensionApi: this,
+ }).api(),
+ onClicked: new EventManager({
+ context,
+ module: "menus",
+ event: "onClicked",
+ extensionApi: this,
+ }).api(),
+
+ create(createProperties) {
+ // event pages require id
+ if (!extension.persistentBackground) {
+ if (!createProperties.id) {
+ throw new ExtensionError(
+ "menus.create requires an id for non-persistent background scripts."
+ );
+ }
+ if (gMenuMap.get(extension).has(createProperties.id)) {
+ throw new ExtensionError(
+ `The menu id ${createProperties.id} already exists in menus.create.`
+ );
+ }
+ }
+
+ // Note that the id is required by the schema. If the addon did not set
+ // it, the implementation of menus.create in the child will add it for
+ // extensions with persistent backgrounds, but not otherwise.
+ let menuItem = new MenuItem(extension, createProperties);
+ gMenuMap.get(extension).set(menuItem.id, menuItem);
+ if (!extension.persistentBackground) {
+ // Only cache properties that are necessary.
+ let cached = {};
+ MenuItem.mergeProps(cached, createProperties);
+ gStartupCache.get(extension).set(menuItem.id, cached);
+ StartupCache.save();
+ }
+ },
+
+ update(id, updateProperties) {
+ let menuItem = gMenuMap.get(extension).get(id);
+ if (!menuItem) {
+ return;
+ }
+ menuItem.setProps(updateProperties);
+
+ // Update the startup cache for non-persistent extensions.
+ if (extension.persistentBackground) {
+ return;
+ }
+
+ let cached = gStartupCache.get(extension).get(id);
+ let reparent =
+ updateProperties.parentId != null &&
+ cached.parentId != updateProperties.parentId;
+ MenuItem.mergeProps(cached, updateProperties);
+ if (reparent) {
+ // The order of menu creation is significant, see reparentInCache.
+ menuItem.reparentInCache();
+ }
+ StartupCache.save();
+ },
+
+ remove(id) {
+ let menuItem = gMenuMap.get(extension).get(id);
+ if (menuItem) {
+ menuItem.remove();
+ }
+ },
+
+ removeAll() {
+ let root = gRootItems.get(extension);
+ if (root) {
+ root.remove();
+ }
+ // Should be empty, just extra assurance.
+ if (!extension.persistentBackground) {
+ let cached = gStartupCache.get(extension);
+ if (cached.size) {
+ cached.clear();
+ StartupCache.save();
+ }
+ }
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-messageDisplay.js b/comm/mail/components/extensions/parent/ext-messageDisplay.js
new file mode 100644
index 0000000000..98ba2dc75c
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-messageDisplay.js
@@ -0,0 +1,348 @@
+/* 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/. */
+
+var { MailConsts } = ChromeUtils.import("resource:///modules/MailConsts.jsm");
+var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+
+/**
+ * Returns the currently displayed messages in the given tab.
+ *
+ * @param {Tab} tab
+ * @returns {nsIMsgHdr[]} Array of nsIMsgHdr
+ */
+function getDisplayedMessages(tab) {
+ let nativeTab = tab.nativeTab;
+ if (tab instanceof TabmailTab) {
+ if (nativeTab.mode.name == "mail3PaneTab") {
+ return nativeTab.chromeBrowser.contentWindow.gDBView.getSelectedMsgHdrs();
+ } else if (nativeTab.mode.name == "mailMessageTab") {
+ return [nativeTab.chromeBrowser.contentWindow.gMessage];
+ }
+ } else if (nativeTab?.messageBrowser) {
+ return [nativeTab.messageBrowser.contentWindow.gMessage];
+ }
+ return [];
+}
+
+/**
+ * Wrapper to convert multiple nsIMsgHdr to MessageHeader objects.
+ *
+ * @param {nsIMsgHdr[]} Array of nsIMsgHdr
+ * @param {ExtensionData} extension
+ * @returns {MessageHeader[]} Array of MessageHeader objects
+ *
+ * @see /mail/components/extensions/schemas/messages.json
+ */
+function convertMessages(messages, extension) {
+ let result = [];
+ for (let msg of messages) {
+ let hdr = convertMessage(msg, extension);
+ if (hdr) {
+ result.push(hdr);
+ }
+ }
+ return result;
+}
+
+/**
+ * Check the users preference on opening new messages in tabs or windows.
+ *
+ * @returns {string} - either "tab" or "window"
+ */
+function getDefaultMessageOpenLocation() {
+ let pref = Services.prefs.getIntPref("mail.openMessageBehavior");
+ return pref == MailConsts.OpenMessageBehavior.NEW_TAB ? "tab" : "window";
+}
+
+/**
+ * Return the msgHdr of the message specified in the properties object. Message
+ * can be specified via properties.headerMessageId or properties.messageId.
+ *
+ * @param {object} properties - @see mail/components/extensions/schemas/messageDisplay.json
+ * @throws ExtensionError if an unknown message has been specified
+ * @returns {nsIMsgHdr} the requested msgHdr
+ */
+function getMsgHdr(properties) {
+ if (properties.headerMessageId) {
+ let msgHdr = MailUtils.getMsgHdrForMsgId(properties.headerMessageId);
+ if (!msgHdr) {
+ throw new ExtensionError(
+ `Unknown or invalid headerMessageId: ${properties.headerMessageId}.`
+ );
+ }
+ return msgHdr;
+ }
+ let msgHdr = messageTracker.getMessage(properties.messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(
+ `Unknown or invalid messageId: ${properties.messageId}.`
+ );
+ }
+ return msgHdr;
+}
+
+this.messageDisplay = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onMessageDisplayed({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ let listener = {
+ async handleEvent(event) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ // `event.target` is an about:message window.
+ let nativeTab = event.target.tabOrWindow;
+ let tab = tabManager.wrapTab(nativeTab);
+ let msg = convertMessage(event.detail, extension);
+ fire.async(tab.convert(), msg);
+ },
+ };
+ windowTracker.addListener("MsgLoaded", listener);
+ return {
+ unregister: () => {
+ windowTracker.removeListener("MsgLoaded", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onMessagesDisplayed({ context, fire }) {
+ const { extension } = this;
+ const { tabManager } = extension;
+ let listener = {
+ async handleEvent(event) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ // `event.target` is an about:message or about:3pane window.
+ let nativeTab = event.target.tabOrWindow;
+ let tab = tabManager.wrapTab(nativeTab);
+ let msgs = getDisplayedMessages(tab);
+ fire.async(tab.convert(), convertMessages(msgs, extension));
+ },
+ };
+ windowTracker.addListener("MsgsLoaded", listener);
+ return {
+ unregister: () => {
+ windowTracker.removeListener("MsgsLoaded", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ /**
+ * Guard to make sure the API waits until the message tab has been fully loaded,
+ * to cope with tabs.onCreated returning tabs very early.
+ *
+ * @param {integer} tabId
+ * @returns {Tab} the fully loaded message tab identified by the given tabId,
+ * or null, if invalid
+ */
+ async function getMessageDisplayTab(tabId) {
+ let msgContentWindow;
+ let tab = tabManager.get(tabId);
+ if (tab?.type == "mail") {
+ // In about:3pane only the messageBrowser needs to be checked for its
+ // load state. The webBrowser is invalid, the multiMessageBrowser can
+ // bypass.
+ if (!tab.nativeTab.chromeBrowser.contentWindow.webBrowser.hidden) {
+ return null;
+ }
+ if (
+ !tab.nativeTab.chromeBrowser.contentWindow.multiMessageBrowser.hidden
+ ) {
+ return tab;
+ }
+ msgContentWindow =
+ tab.nativeTab.chromeBrowser.contentWindow.messageBrowser
+ .contentWindow;
+ } else if (tab?.type == "messageDisplay") {
+ msgContentWindow =
+ tab instanceof TabmailTab
+ ? tab.nativeTab.chromeBrowser.contentWindow
+ : tab.nativeTab.messageBrowser.contentWindow;
+ } else {
+ return null;
+ }
+
+ // Make sure the content window has been fully loaded.
+ await new Promise(resolve => {
+ if (msgContentWindow.document.readyState == "complete") {
+ resolve();
+ } else {
+ msgContentWindow.addEventListener(
+ "load",
+ () => {
+ resolve();
+ },
+ { once: true }
+ );
+ }
+ });
+
+ // Wait until the message display process has been initiated.
+ await new Promise(resolve => {
+ if (msgContentWindow.msgLoading || msgContentWindow.msgLoaded) {
+ resolve();
+ } else {
+ msgContentWindow.addEventListener(
+ "messageURIChanged",
+ () => {
+ resolve();
+ },
+ { once: true }
+ );
+ }
+ });
+
+ // Wait until the message display process has been finished.
+ await new Promise(resolve => {
+ if (msgContentWindow.msgLoaded) {
+ resolve();
+ } else {
+ msgContentWindow.addEventListener(
+ "MsgLoaded",
+ () => {
+ resolve();
+ },
+ { once: true }
+ );
+ }
+ });
+
+ // If there is no gMessage, then the display has been cleared.
+ return msgContentWindow.gMessage ? tab : null;
+ }
+
+ let { extension } = context;
+ let { tabManager } = extension;
+ return {
+ messageDisplay: {
+ onMessageDisplayed: new EventManager({
+ context,
+ module: "messageDisplay",
+ event: "onMessageDisplayed",
+ extensionApi: this,
+ }).api(),
+ onMessagesDisplayed: new EventManager({
+ context,
+ module: "messageDisplay",
+ event: "onMessagesDisplayed",
+ extensionApi: this,
+ }).api(),
+ async getDisplayedMessage(tabId) {
+ let tab = await getMessageDisplayTab(tabId);
+ if (!tab) {
+ return null;
+ }
+ let messages = getDisplayedMessages(tab);
+ if (messages.length != 1) {
+ return null;
+ }
+ return convertMessage(messages[0], extension);
+ },
+ async getDisplayedMessages(tabId) {
+ let tab = await getMessageDisplayTab(tabId);
+ if (!tab) {
+ return [];
+ }
+ let messages = getDisplayedMessages(tab);
+ return convertMessages(messages, extension);
+ },
+ async open(properties) {
+ if (
+ ["messageId", "headerMessageId", "file"].reduce(
+ (count, value) => (properties[value] ? count + 1 : count),
+ 0
+ ) != 1
+ ) {
+ throw new ExtensionError(
+ "Exactly one of messageId, headerMessageId or file must be specified."
+ );
+ }
+
+ let messageURI;
+ if (properties.file) {
+ let realFile = await getRealFileForFile(properties.file);
+ messageURI = Services.io
+ .newFileURI(realFile)
+ .mutate()
+ .setQuery("type=application/x-message-display")
+ .finalize().spec;
+ } else {
+ let msgHdr = getMsgHdr(properties);
+ if (msgHdr.folder) {
+ messageURI = msgHdr.folder.getUriForMsg(msgHdr);
+ } else {
+ // Add the application/x-message-display type to the url, if missing.
+ // The slash is escaped when setting the type via searchParams, but
+ // core code needs it unescaped.
+ let url = new URL(msgHdr.getStringProperty("dummyMsgUrl"));
+ url.searchParams.delete("type");
+ messageURI = `${url.href}${
+ url.searchParams.toString() ? "&" : "?"
+ }type=application/x-message-display`;
+ }
+ }
+
+ let tab;
+ switch (properties.location || getDefaultMessageOpenLocation()) {
+ case "tab":
+ {
+ let normalWindow = await getNormalWindowReady(
+ context,
+ properties.windowId
+ );
+ let active = properties.active ?? true;
+ let tabmail = normalWindow.document.getElementById("tabmail");
+ let currentTab = tabmail.selectedTab;
+ let nativeTabInfo = tabmail.openTab("mailMessageTab", {
+ messageURI,
+ background: !active,
+ });
+ await new Promise(resolve =>
+ nativeTabInfo.chromeBrowser.addEventListener(
+ "MsgLoaded",
+ resolve,
+ { once: true }
+ )
+ );
+ tab = tabManager.convert(nativeTabInfo, currentTab);
+ }
+ break;
+
+ case "window":
+ {
+ // Handle window location.
+ let topNormalWindow = await getNormalWindowReady();
+ let messageWindow = topNormalWindow.MsgOpenNewWindowForMessage(
+ Services.io.newURI(messageURI)
+ );
+ await new Promise(resolve =>
+ messageWindow.addEventListener("MsgLoaded", resolve, {
+ once: true,
+ })
+ );
+ tab = tabManager.convert(messageWindow);
+ }
+ break;
+ }
+ return tab;
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-messageDisplayAction.js b/comm/mail/components/extensions/parent/ext-messageDisplayAction.js
new file mode 100644
index 0000000000..026ddfc736
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-messageDisplayAction.js
@@ -0,0 +1,251 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ToolbarButtonAPI",
+ "resource:///modules/ExtensionToolbarButtons.jsm"
+);
+
+var { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+var { makeWidgetId } = ExtensionCommon;
+
+const messageDisplayActionMap = new WeakMap();
+
+this.messageDisplayAction = class extends ToolbarButtonAPI {
+ static for(extension) {
+ return messageDisplayActionMap.get(extension);
+ }
+
+ async onManifestEntry(entryName) {
+ await super.onManifestEntry(entryName);
+ messageDisplayActionMap.set(this.extension, this);
+ }
+
+ close() {
+ super.close();
+ messageDisplayActionMap.delete(this.extension);
+ windowTracker.removeListener("TabSelect", this);
+ }
+
+ constructor(extension) {
+ super(extension, global);
+ this.manifest_name = "message_display_action";
+ this.manifestName = "messageDisplayAction";
+ this.manifest = extension.manifest[this.manifest_name];
+ this.moduleName = this.manifestName;
+
+ this.windowURLs = [
+ "chrome://messenger/content/messenger.xhtml",
+ "chrome://messenger/content/messageWindow.xhtml",
+ ];
+ this.toolboxId = "header-view-toolbox";
+ this.toolbarId = "header-view-toolbar";
+
+ windowTracker.addListener("TabSelect", this);
+ }
+
+ static onUninstall(extensionId) {
+ let widgetId = makeWidgetId(extensionId);
+ let id = `${widgetId}-messageDisplayAction-toolbarbutton`;
+ let toolbar = "header-view-toolbar";
+
+ // Check all possible windows and remove the toolbarbutton if found.
+ // Sadly we have to hardcode these values here, as the add-on is already
+ // shutdown when onUninstall is called.
+ let windowURLs = [
+ "chrome://messenger/content/messenger.xhtml",
+ "chrome://messenger/content/messageWindow.xhtml",
+ ];
+ for (let windowURL of windowURLs) {
+ for (let setName of ["currentset", "extensionset"]) {
+ let set = Services.xulStore
+ .getValue(windowURL, toolbar, setName)
+ .split(",");
+ let newSet = set.filter(e => e != id);
+ if (newSet.length < set.length) {
+ Services.xulStore.setValue(
+ windowURL,
+ toolbar,
+ setName,
+ newSet.join(",")
+ );
+ }
+ }
+ }
+ }
+
+ /**
+ * Overrides the super class to update every about:message in this window.
+ */
+ paint(window) {
+ window.addEventListener("aboutMessageLoaded", this);
+ for (let bc of window.browsingContext.getAllBrowsingContextsInSubtree()) {
+ if (bc.currentURI.spec == "about:message") {
+ super.paint(bc.window);
+ }
+ }
+ }
+
+ /**
+ * Overrides the super class to update every about:message in this window.
+ */
+ unpaint(window) {
+ window.removeEventListener("aboutMessageLoaded", this);
+ for (let bc of window.browsingContext.getAllBrowsingContextsInSubtree()) {
+ if (bc.currentURI.spec == "about:message") {
+ super.unpaint(bc.window);
+ }
+ }
+ }
+
+ /**
+ * Overrides the super class to update every about:message in this window.
+ */
+ async updateWindow(window) {
+ for (let bc of window.browsingContext.getAllBrowsingContextsInSubtree()) {
+ if (bc.currentURI.spec == "about:message") {
+ super.updateWindow(bc.window);
+ }
+ }
+ }
+
+ /**
+ * Overrides the super class where `target` is a tab, to update
+ * about:message instead of the window.
+ */
+ async updateOnChange(target) {
+ if (!target) {
+ await super.updateOnChange(target);
+ return;
+ }
+
+ let window = Cu.getGlobalForObject(target);
+ if (window == target) {
+ await super.updateOnChange(target);
+ return;
+ }
+
+ let tabmail = window.top.document.getElementById("tabmail");
+ if (!tabmail || target != tabmail.selectedTab) {
+ return;
+ }
+
+ switch (target.mode.name) {
+ case "mail3PaneTab":
+ await this.updateWindow(
+ target.chromeBrowser.contentWindow.messageBrowser.contentWindow
+ );
+ break;
+ case "mailMessageTab":
+ await this.updateWindow(target.chromeBrowser.contentWindow);
+ break;
+ }
+ }
+
+ handleEvent(event) {
+ super.handleEvent(event);
+ let window = event.target.ownerGlobal;
+
+ switch (event.type) {
+ case "aboutMessageLoaded":
+ // Add the toolbar button to any about:message that comes along.
+ super.paint(event.target);
+ break;
+ case "popupshowing":
+ const menu = event.target;
+ if (menu.tagName != "menupopup") {
+ return;
+ }
+
+ const trigger = menu.triggerNode;
+ const node = window.document.getElementById(this.id);
+ const contexts = ["header-toolbar-context-menu"];
+ if (contexts.includes(menu.id) && node && node.contains(trigger)) {
+ global.actionContextMenu({
+ tab: window.tabOrWindow,
+ pageUrl: window.getMessagePaneBrowser().currentURI.spec,
+ extension: this.extension,
+ onMessageDisplayAction: true,
+ menu,
+ });
+ }
+
+ if (
+ menu.dataset.actionMenu == "messageDisplayAction" &&
+ this.extension.id == menu.dataset.extensionId
+ ) {
+ global.actionContextMenu({
+ tab: window.tabOrWindow,
+ pageUrl: window.getMessagePaneBrowser().currentURI.spec,
+ extension: this.extension,
+ inMessageDisplayActionMenu: true,
+ menu,
+ });
+ }
+ break;
+ }
+ }
+
+ /**
+ * Overrides the super class to trigger the action in the current about:message.
+ */
+ async triggerAction(window, options) {
+ // Supported message browsers:
+ // - in mail tab (browser could be hidden)
+ // - in message tab
+ // - in message window
+
+ // The passed in window could be the window of one of the supported message
+ // browsers already. To know if the browser is hidden, always re-search the
+ // message window and start at the top.
+ let tabmail = window.top.document.getElementById("tabmail");
+ if (tabmail) {
+ // A mail tab or a message tab.
+ let isHidden =
+ tabmail.currentAbout3Pane &&
+ tabmail.currentAbout3Pane.messageBrowser.hidden;
+
+ if (tabmail.currentAboutMessage && !isHidden) {
+ return super.triggerAction(tabmail.currentAboutMessage, options);
+ }
+ } else if (window.top.messageBrowser) {
+ // A message window.
+ return super.triggerAction(
+ window.top.messageBrowser.contentWindow,
+ options
+ );
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns an element in the toolbar, which is to be used as default insertion
+ * point for new toolbar buttons in non-customizable toolbars.
+ *
+ * May return null to append new buttons to the end of the toolbar.
+ *
+ * @param {DOMElement} toolbar - a toolbar node
+ * @returns {DOMElement} a node which is to be used as insertion point, or null
+ */
+ getNonCustomizableToolbarInsertionPoint(toolbar) {
+ return toolbar.querySelector("#otherActionsButton");
+ }
+
+ makeButton(window) {
+ let button = super.makeButton(window);
+ button.classList.add("message-header-view-button");
+ // The header toolbar has no associated context menu. Add one directly to
+ // this button.
+ button.setAttribute("context", "header-toolbar-context-menu");
+ return button;
+ }
+};
+
+global.messageDisplayActionFor = this.messageDisplayAction.for;
diff --git a/comm/mail/components/extensions/parent/ext-messages.js b/comm/mail/components/extensions/parent/ext-messages.js
new file mode 100644
index 0000000000..7d03b3fa62
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-messages.js
@@ -0,0 +1,1563 @@
+/* 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/. */
+
+ChromeUtils.defineESModuleGetters(this, {
+ AttachmentInfo: "resource:///modules/AttachmentInfo.sys.mjs",
+});
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailServices",
+ "resource:///modules/MailServices.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "MessageArchiver",
+ "resource:///modules/MessageArchiver.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "MimeParser",
+ "resource:///modules/mimeParser.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "MsgHdrToMimeMessage",
+ "resource:///modules/gloda/MimeMessage.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "NetUtil",
+ "resource://gre/modules/NetUtil.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "jsmime",
+ "resource:///modules/jsmime.jsm"
+);
+
+var { MailStringUtils } = ChromeUtils.import(
+ "resource:///modules/MailStringUtils.jsm"
+);
+
+// eslint-disable-next-line mozilla/reject-importGlobalProperties
+Cu.importGlobalProperties(["File", "IOUtils", "PathUtils"]);
+
+var { DefaultMap } = ExtensionUtils;
+
+let messenger = Cc["@mozilla.org/messenger;1"].createInstance(Ci.nsIMessenger);
+
+/**
+ * Takes a part of a MIME message (as retrieved with MsgHdrToMimeMessage) and
+ * filters out the properties we don't want to send to extensions.
+ */
+function convertMessagePart(part) {
+ let partObject = {};
+ for (let key of ["body", "contentType", "name", "partName", "size"]) {
+ if (key in part) {
+ partObject[key] = part[key];
+ }
+ }
+
+ // Decode headers. This also takes care of headers, which still include
+ // encoded words and need to be RFC 2047 decoded.
+ if ("headers" in part) {
+ partObject.headers = {};
+ for (let header of Object.keys(part.headers)) {
+ partObject.headers[header] = part.headers[header].map(h =>
+ MailServices.mimeConverter.decodeMimeHeader(
+ h,
+ null,
+ false /* override_charset */,
+ true /* eatContinuations */
+ )
+ );
+ }
+ }
+
+ if ("parts" in part && Array.isArray(part.parts) && part.parts.length > 0) {
+ partObject.parts = part.parts.map(convertMessagePart);
+ }
+ return partObject;
+}
+
+async function convertAttachment(attachment) {
+ let rv = {
+ contentType: attachment.contentType,
+ name: attachment.name,
+ size: attachment.size,
+ partName: attachment.partName,
+ };
+
+ if (attachment.contentType.startsWith("message/")) {
+ // The attached message may not have been seen/opened yet, create a dummy
+ // msgHdr.
+ let attachedMsgHdr = new nsDummyMsgHeader();
+
+ attachedMsgHdr.setStringProperty("dummyMsgUrl", attachment.url);
+ attachedMsgHdr.recipients = attachment.headers.to;
+ attachedMsgHdr.ccList = attachment.headers.cc;
+ attachedMsgHdr.bccList = attachment.headers.bcc;
+ attachedMsgHdr.author = attachment.headers.from?.[0] || "";
+ attachedMsgHdr.subject = attachment.headers.subject?.[0] || "";
+
+ let hdrDate = attachment.headers.date?.[0];
+ attachedMsgHdr.date = hdrDate ? Date.parse(hdrDate) * 1000 : 0;
+
+ let hdrId = attachment.headers["message-id"]?.[0];
+ attachedMsgHdr.messageId = hdrId ? hdrId.replace(/^<|>$/g, "") : "";
+
+ rv.message = convertMessage(attachedMsgHdr);
+ }
+
+ return rv;
+}
+
+/**
+ * @typedef MimeMessagePart
+ * @property {MimeMessagePart[]} [attachments] - flat list of attachment parts
+ * found in any of the nested mime parts
+ * @property {string} [body] - the body of the part
+ * @property {Uint8Array} [raw] - the raw binary content of the part
+ * @property {string} [contentType]
+ * @property {string} headers - key-value object with key being a header name
+ * and value an array with all header values found
+ * @property {string} [name] - filename, if part is an attachment
+ * @property {string} partName - name of the mime part (e.g: "1.2")
+ * @property {MimeMessagePart[]} [parts] - nested mime parts
+ * @property {string} [size] - size of the part
+ * @property {string} [url] - message url
+ */
+
+/**
+ * Returns attachments found in the message belonging to the given nsIMsgHdr.
+ *
+ * @param {nsIMsgHdr} msgHdr
+ * @param {boolean} includeNestedAttachments - Whether to return all attachments,
+ * including attachments from nested mime parts.
+ * @returns {Promise<MimeMessagePart[]>}
+ */
+async function getAttachments(msgHdr, includeNestedAttachments = false) {
+ let mimeMsg = await getMimeMessage(msgHdr);
+ if (!mimeMsg) {
+ return null;
+ }
+
+ // Reduce returned attachments according to includeNestedAttachments.
+ let level = mimeMsg.partName ? mimeMsg.partName.split(".").length : 0;
+ return mimeMsg.attachments.filter(
+ a => includeNestedAttachments || a.partName.split(".").length == level + 2
+ );
+}
+
+/**
+ * Returns the attachment identified by the provided partName.
+ *
+ * @param {nsIMsgHdr} msgHdr
+ * @param {string} partName
+ * @param {object} [options={}] - If the includeRaw property is truthy the raw
+ * attachment contents are included.
+ * @returns {Promise<MimeMessagePart>}
+ */
+async function getAttachment(msgHdr, partName, options = {}) {
+ // It's not ideal to have to call MsgHdrToMimeMessage here again, but we need
+ // the name of the attached file, plus this also gives us the URI without having
+ // to jump through a lot of hoops.
+ let attachment = await getMimeMessage(msgHdr, partName);
+ if (!attachment) {
+ return null;
+ }
+
+ if (options.includeRaw) {
+ let channel = Services.io.newChannelFromURI(
+ Services.io.newURI(attachment.url),
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+
+ attachment.raw = await new Promise((resolve, reject) => {
+ let listener = Cc["@mozilla.org/network/stream-loader;1"].createInstance(
+ Ci.nsIStreamLoader
+ );
+ listener.init({
+ onStreamComplete(loader, context, status, resultLength, result) {
+ if (Components.isSuccessCode(status)) {
+ resolve(Uint8Array.from(result));
+ } else {
+ reject(
+ new ExtensionError(
+ `Failed to read attachment ${attachment.url} content: ${status}`
+ )
+ );
+ }
+ },
+ });
+ channel.asyncOpen(listener, null);
+ });
+ }
+
+ return attachment;
+}
+
+/**
+ * Returns the <part> parameter of the dummyMsgUrl of the provided nsIMsgHdr.
+ *
+ * @param {nsIMsgHdr} msgHdr
+ * @returns {string}
+ */
+function getSubMessagePartName(msgHdr) {
+ if (msgHdr.folder || !msgHdr.getStringProperty("dummyMsgUrl")) {
+ return "";
+ }
+
+ return new URL(msgHdr.getStringProperty("dummyMsgUrl")).searchParams.get(
+ "part"
+ );
+}
+
+/**
+ * Returns the nsIMsgHdr of the outer message, if the provided nsIMsgHdr belongs
+ * to a message which is actually an attachment of another message. Returns null
+ * otherwise.
+ *
+ * @param {nsIMsgHdr} msgHdr
+ * @returns {nsIMsgHdr}
+ */
+function getParentMsgHdr(msgHdr) {
+ if (msgHdr.folder || !msgHdr.getStringProperty("dummyMsgUrl")) {
+ return null;
+ }
+
+ let url = new URL(msgHdr.getStringProperty("dummyMsgUrl"));
+
+ if (url.protocol == "news:") {
+ let newsUrl = `news-message://${url.hostname}/${url.searchParams.get(
+ "group"
+ )}#${url.searchParams.get("key")}`;
+ return messenger.msgHdrFromURI(newsUrl);
+ }
+
+ if (url.protocol == "mailbox:") {
+ // This could be a sub-message of a message opened from file.
+ let fileUrl = `file://${url.pathname}`;
+ let parentMsgHdr = messageTracker._dummyMessageHeaders.get(fileUrl);
+ if (parentMsgHdr) {
+ return parentMsgHdr;
+ }
+ }
+ // Everything else should be a mailbox:// or an imap:// url.
+ let params = Array.from(url.searchParams, p => p[0]).filter(
+ p => !["number"].includes(p)
+ );
+ for (let param of params) {
+ url.searchParams.delete(param);
+ }
+ return Services.io.newURI(url.href).QueryInterface(Ci.nsIMsgMessageUrl)
+ .messageHeader;
+}
+
+/**
+ * Get the raw message for a given nsIMsgHdr.
+ *
+ * @param aMsgHdr - The message header to retrieve the raw message for.
+ * @returns {Promise<string>} - Binary string of the raw message.
+ */
+async function getRawMessage(msgHdr) {
+ // If this message is a sub-message (an attachment of another message), get it
+ // as an attachment from the parent message and return its raw content.
+ let subMsgPartName = getSubMessagePartName(msgHdr);
+ if (subMsgPartName) {
+ let parentMsgHdr = getParentMsgHdr(msgHdr);
+ let attachment = await getAttachment(parentMsgHdr, subMsgPartName, {
+ includeRaw: true,
+ });
+ return attachment.raw.reduce(
+ (prev, curr) => prev + String.fromCharCode(curr),
+ ""
+ );
+ }
+
+ // Messages opened from file do not have a folder property, but
+ // have their url stored as a string property.
+ let msgUri = msgHdr.folder
+ ? msgHdr.folder.generateMessageURI(msgHdr.messageKey)
+ : msgHdr.getStringProperty("dummyMsgUrl");
+
+ let service = MailServices.messageServiceFromURI(msgUri);
+ return new Promise((resolve, reject) => {
+ let streamlistener = {
+ _data: [],
+ _stream: null,
+ onDataAvailable(aRequest, aInputStream, aOffset, aCount) {
+ if (!this._stream) {
+ this._stream = Cc[
+ "@mozilla.org/scriptableinputstream;1"
+ ].createInstance(Ci.nsIScriptableInputStream);
+ this._stream.init(aInputStream);
+ }
+ this._data.push(this._stream.read(aCount));
+ },
+ onStartRequest() {},
+ onStopRequest(request, status) {
+ if (Components.isSuccessCode(status)) {
+ resolve(this._data.join(""));
+ } else {
+ reject(
+ new ExtensionError(
+ `Error while streaming message <${msgUri}>: ${status}`
+ )
+ );
+ }
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIStreamListener",
+ "nsIRequestObserver",
+ ]),
+ };
+
+ // This is not using aConvertData and therefore works for news:// messages.
+ service.streamMessage(
+ msgUri,
+ streamlistener,
+ null, // aMsgWindow
+ null, // aUrlListener
+ false, // aConvertData
+ "" //aAdditionalHeader
+ );
+ });
+}
+
+/**
+ * Returns MIME parts found in the message identified by the given nsIMsgHdr.
+ *
+ * @param {nsIMsgHdr} msgHdr
+ * @param {string} partName - Return only a specific mime part.
+ * @returns {Promise<MimeMessagePart>}
+ */
+async function getMimeMessage(msgHdr, partName = "") {
+ // If this message is a sub-message (an attachment of another message), get the
+ // mime parts of the parent message and return the part of the sub-message.
+ let subMsgPartName = getSubMessagePartName(msgHdr);
+ if (subMsgPartName) {
+ let parentMsgHdr = getParentMsgHdr(msgHdr);
+ if (!parentMsgHdr) {
+ return null;
+ }
+
+ let mimeMsg = await getMimeMessage(parentMsgHdr, partName);
+ if (!mimeMsg) {
+ return null;
+ }
+
+ // If <partName> was specified, the returned mime message is just that part,
+ // no further processing needed. But prevent x-ray vision into the parent.
+ if (partName) {
+ if (partName.split(".").length > subMsgPartName.split(".").length) {
+ return mimeMsg;
+ }
+ return null;
+ }
+
+ // Limit mimeMsg and attachments to the requested <subMessagePart>.
+ let findSubPart = (parts, partName) => {
+ let match = parts.find(a => partName.startsWith(a.partName));
+ if (!match) {
+ throw new ExtensionError(
+ `Unexpected Error: Part ${partName} not found.`
+ );
+ }
+ return match.partName == partName
+ ? match
+ : findSubPart(match.parts, partName);
+ };
+ let subMimeMsg = findSubPart(mimeMsg.parts, subMsgPartName);
+
+ if (mimeMsg.attachments) {
+ subMimeMsg.attachments = mimeMsg.attachments.filter(
+ a =>
+ a.partName != subMsgPartName && a.partName.startsWith(subMsgPartName)
+ );
+ }
+ return subMimeMsg;
+ }
+
+ let mimeMsg = await new Promise(resolve => {
+ MsgHdrToMimeMessage(
+ msgHdr,
+ null,
+ (_msgHdr, mimeMsg) => {
+ mimeMsg.attachments = mimeMsg.allInlineAttachments;
+ resolve(mimeMsg);
+ },
+ true,
+ { examineEncryptedParts: true }
+ );
+ });
+
+ return partName
+ ? mimeMsg.attachments.find(a => a.partName == partName)
+ : mimeMsg;
+}
+
+this.messages = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onNewMailReceived({ context, fire }) {
+ let listener = async (event, folder, newMessages) => {
+ let { extension } = this;
+ // The msgHdr could be gone after the wakeup, convert it early.
+ let page = await messageListTracker.startList(newMessages, extension);
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(convertFolder(folder), page);
+ };
+ messageTracker.on("messages-received", listener);
+ return {
+ unregister: () => {
+ messageTracker.off("messages-received", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onUpdated({ context, fire }) {
+ let listener = async (event, message, properties) => {
+ let { extension } = this;
+ // The msgHdr could be gone after the wakeup, convert it early.
+ let convertedMessage = convertMessage(message, extension);
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(convertedMessage, properties);
+ };
+ messageTracker.on("message-updated", listener);
+ return {
+ unregister: () => {
+ messageTracker.off("message-updated", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onMoved({ context, fire }) {
+ let listener = async (event, srcMessages, dstMessages) => {
+ let { extension } = this;
+ // The msgHdr could be gone after the wakeup, convert them early.
+ let srcPage = await messageListTracker.startList(
+ srcMessages,
+ extension
+ );
+ let dstPage = await messageListTracker.startList(
+ dstMessages,
+ extension
+ );
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(srcPage, dstPage);
+ };
+ messageTracker.on("messages-moved", listener);
+ return {
+ unregister: () => {
+ messageTracker.off("messages-moved", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onCopied({ context, fire }) {
+ let listener = async (event, srcMessages, dstMessages) => {
+ let { extension } = this;
+ // The msgHdr could be gone after the wakeup, convert them early.
+ let srcPage = await messageListTracker.startList(
+ srcMessages,
+ extension
+ );
+ let dstPage = await messageListTracker.startList(
+ dstMessages,
+ extension
+ );
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(srcPage, dstPage);
+ };
+ messageTracker.on("messages-copied", listener);
+ return {
+ unregister: () => {
+ messageTracker.off("messages-copied", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ onDeleted({ context, fire }) {
+ let listener = async (event, deletedMessages) => {
+ let { extension } = this;
+ // The msgHdr could be gone after the wakeup, convert them early.
+ let deletedPage = await messageListTracker.startList(
+ deletedMessages,
+ extension
+ );
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(deletedPage);
+ };
+ messageTracker.on("messages-deleted", listener);
+ return {
+ unregister: () => {
+ messageTracker.off("messages-deleted", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ const { extension } = this;
+ const { tabManager } = extension;
+
+ function collectMessagesInFolders(messageIds) {
+ let folderMap = new DefaultMap(() => new Set());
+
+ for (let messageId of messageIds) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+
+ let msgHeaderSet = folderMap.get(msgHdr.folder);
+ msgHeaderSet.add(msgHdr);
+ }
+
+ return folderMap;
+ }
+
+ async function createTempFileMessage(msgHdr) {
+ let rawBinaryString = await getRawMessage(msgHdr);
+ let pathEmlFile = await IOUtils.createUniqueFile(
+ PathUtils.tempDir,
+ encodeURIComponent(msgHdr.messageId).replaceAll(/[/:*?\"<>|]/g, "_") +
+ ".eml",
+ 0o600
+ );
+
+ let emlFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ emlFile.initWithPath(pathEmlFile);
+ let extAppLauncher = Cc[
+ "@mozilla.org/uriloader/external-helper-app-service;1"
+ ].getService(Ci.nsPIExternalAppLauncher);
+ extAppLauncher.deleteTemporaryFileOnExit(emlFile);
+
+ let buffer = MailStringUtils.byteStringToUint8Array(rawBinaryString);
+ await IOUtils.write(pathEmlFile, buffer);
+ return emlFile;
+ }
+
+ async function moveOrCopyMessages(messageIds, { accountId, path }, isMove) {
+ if (
+ !context.extension.hasPermission("accountsRead") ||
+ !context.extension.hasPermission("messagesMove")
+ ) {
+ throw new ExtensionError(
+ `Using messages.${
+ isMove ? "move" : "copy"
+ }() requires the "accountsRead" and the "messagesMove" permission`
+ );
+ }
+ let destinationURI = folderPathToURI(accountId, path);
+ let destinationFolder =
+ MailServices.folderLookup.getFolderForURL(destinationURI);
+ try {
+ let promises = [];
+ let folderMap = collectMessagesInFolders(messageIds);
+ for (let [sourceFolder, msgHeaderSet] of folderMap.entries()) {
+ if (sourceFolder == destinationFolder) {
+ continue;
+ }
+ let msgHeaders = [...msgHeaderSet];
+
+ // Special handling for external messages.
+ if (!sourceFolder) {
+ if (isMove) {
+ throw new ExtensionError(
+ `Operation not permitted for external messages`
+ );
+ }
+
+ for (let msgHdr of msgHeaders) {
+ let file;
+ let fileUrl = msgHdr.getStringProperty("dummyMsgUrl");
+ if (fileUrl.startsWith("file://")) {
+ file = Services.io
+ .newURI(fileUrl)
+ .QueryInterface(Ci.nsIFileURL).file;
+ } else {
+ file = await createTempFileMessage(msgHdr);
+ }
+
+ promises.push(
+ new Promise((resolve, reject) => {
+ MailServices.copy.copyFileMessage(
+ file,
+ destinationFolder,
+ /* msgToReplace */ null,
+ /* isDraftOrTemplate */ false,
+ /* aMsgFlags */ Ci.nsMsgMessageFlags.Read,
+ /* aMsgKeywords */ "",
+ {
+ OnStartCopy() {},
+ OnProgress(progress, progressMax) {},
+ SetMessageKey(key) {},
+ GetMessageId(messageId) {},
+ OnStopCopy(status) {
+ if (status == Cr.NS_OK) {
+ resolve();
+ } else {
+ reject(status);
+ }
+ },
+ },
+ /* msgWindow */ null
+ );
+ })
+ );
+ }
+ continue;
+ }
+
+ // Since the archiver falls back to copy if delete is not supported,
+ // lets do that here as well.
+ promises.push(
+ new Promise((resolve, reject) => {
+ MailServices.copy.copyMessages(
+ sourceFolder,
+ msgHeaders,
+ destinationFolder,
+ isMove && sourceFolder.canDeleteMessages,
+ {
+ OnStartCopy() {},
+ OnProgress(progress, progressMax) {},
+ SetMessageKey(key) {},
+ GetMessageId(messageId) {},
+ OnStopCopy(status) {
+ if (status == Cr.NS_OK) {
+ resolve();
+ } else {
+ reject(status);
+ }
+ },
+ },
+ /* msgWindow */ null,
+ /* allowUndo */ true
+ );
+ })
+ );
+ }
+ await Promise.all(promises);
+ } catch (ex) {
+ console.error(ex);
+ throw new ExtensionError(
+ `Error ${isMove ? "moving" : "copying"} message: ${ex.message}`
+ );
+ }
+ }
+
+ return {
+ messages: {
+ onNewMailReceived: new EventManager({
+ context,
+ module: "messages",
+ event: "onNewMailReceived",
+ extensionApi: this,
+ }).api(),
+ onUpdated: new EventManager({
+ context,
+ module: "messages",
+ event: "onUpdated",
+ extensionApi: this,
+ }).api(),
+ onMoved: new EventManager({
+ context,
+ module: "messages",
+ event: "onMoved",
+ extensionApi: this,
+ }).api(),
+ onCopied: new EventManager({
+ context,
+ module: "messages",
+ event: "onCopied",
+ extensionApi: this,
+ }).api(),
+ onDeleted: new EventManager({
+ context,
+ module: "messages",
+ event: "onDeleted",
+ extensionApi: this,
+ }).api(),
+ async list({ accountId, path }) {
+ let uri = folderPathToURI(accountId, path);
+ let folder = MailServices.folderLookup.getFolderForURL(uri);
+
+ if (!folder) {
+ throw new ExtensionError(`Folder not found: ${path}`);
+ }
+
+ return messageListTracker.startList(
+ folder.messages,
+ context.extension
+ );
+ },
+ async continueList(messageListId) {
+ let messageList = messageListTracker.getList(
+ messageListId,
+ context.extension
+ );
+ return messageListTracker.getNextPage(messageList);
+ },
+ async get(messageId) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ let messageHeader = convertMessage(msgHdr, context.extension);
+ if (messageHeader.id != messageId) {
+ throw new ExtensionError(
+ "Unexpected Error: Returned message does not equal requested message."
+ );
+ }
+ return messageHeader;
+ },
+ async getFull(messageId) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ let mimeMsg = await getMimeMessage(msgHdr);
+ if (!mimeMsg) {
+ throw new ExtensionError(`Error reading message ${messageId}`);
+ }
+ if (msgHdr.flags & Ci.nsMsgMessageFlags.Partial) {
+ // Do not include fake body.
+ mimeMsg.parts = [];
+ }
+ return convertMessagePart(mimeMsg);
+ },
+ async getRaw(messageId, options) {
+ let data_format = options?.data_format;
+ if (!["File", "BinaryString"].includes(data_format)) {
+ data_format =
+ extension.manifestVersion < 3 ? "BinaryString" : "File";
+ }
+
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ try {
+ let raw = await getRawMessage(msgHdr);
+ if (data_format == "File") {
+ // Convert binary string to Uint8Array and return a File.
+ let bytes = new Uint8Array(raw.length);
+ for (let i = 0; i < raw.length; i++) {
+ bytes[i] = raw.charCodeAt(i) & 0xff;
+ }
+ return new File([bytes], `message-${messageId}.eml`, {
+ type: "message/rfc822",
+ });
+ }
+ return raw;
+ } catch (ex) {
+ console.error(ex);
+ throw new ExtensionError(`Error reading message ${messageId}`);
+ }
+ },
+ async listAttachments(messageId) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ let attachments = await getAttachments(msgHdr);
+ for (let i = 0; i < attachments.length; i++) {
+ attachments[i] = await convertAttachment(attachments[i]);
+ }
+ return attachments;
+ },
+ async getAttachmentFile(messageId, partName) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ let attachment = await getAttachment(msgHdr, partName, {
+ includeRaw: true,
+ });
+ if (!attachment) {
+ throw new ExtensionError(
+ `Part ${partName} not found in message ${messageId}.`
+ );
+ }
+ return new File([attachment.raw], attachment.name, {
+ type: attachment.contentType,
+ });
+ },
+ async openAttachment(messageId, partName, tabId) {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ let attachment = await getAttachment(msgHdr, partName);
+ if (!attachment) {
+ throw new ExtensionError(
+ `Part ${partName} not found in message ${messageId}.`
+ );
+ }
+ let attachmentInfo = new AttachmentInfo({
+ contentType: attachment.contentType,
+ url: attachment.url,
+ name: attachment.name,
+ uri: msgHdr.folder.getUriForMsg(msgHdr),
+ isExternalAttachment: attachment.isExternal,
+ message: msgHdr,
+ });
+ let tab = tabManager.get(tabId);
+ try {
+ // Content tabs or content windows use browser, while mail and message
+ // tabs use chromeBrowser.
+ let browser = tab.nativeTab.chromeBrowser || tab.nativeTab.browser;
+ await attachmentInfo.open(browser.browsingContext);
+ } catch (ex) {
+ throw new ExtensionError(
+ `Part ${partName} could not be opened: ${ex}.`
+ );
+ }
+ },
+ async query(queryInfo) {
+ let composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ const includesContent = (folder, parts, searchTerm) => {
+ if (!parts || parts.length == 0) {
+ return false;
+ }
+ for (let part of parts) {
+ if (
+ coerceBodyToPlaintext(folder, part).includes(searchTerm) ||
+ includesContent(folder, part.parts, searchTerm)
+ ) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ const coerceBodyToPlaintext = (folder, part) => {
+ if (!part || !part.body) {
+ return "";
+ }
+ if (part.contentType == "text/plain") {
+ return part.body;
+ }
+ // text/enriched gets transformed into HTML by libmime
+ if (
+ part.contentType == "text/html" ||
+ part.contentType == "text/enriched"
+ ) {
+ return folder.convertMsgSnippetToPlainText(part.body);
+ }
+ return "";
+ };
+
+ /**
+ * Prepare name and email properties of the address object returned by
+ * MailServices.headerParser.makeFromDisplayAddress() to be lower case.
+ * Also fix the name being wrongly returned in the email property, if
+ * the address was just a single name.
+ */
+ const prepareAddress = displayAddr => {
+ let email = displayAddr.email?.toLocaleLowerCase();
+ let name = displayAddr.name?.toLocaleLowerCase();
+ if (email && !name && !email.includes("@")) {
+ name = email;
+ email = null;
+ }
+ return { name, email };
+ };
+
+ /**
+ * Check multiple addresses if they match the provided search address.
+ *
+ * @returns A boolean indicating if search was successful.
+ */
+ const searchInMultipleAddresses = (searchAddress, addresses) => {
+ // Return on first positive match.
+ for (let address of addresses) {
+ let nameMatched =
+ searchAddress.name &&
+ address.name &&
+ address.name.includes(searchAddress.name);
+
+ // Check for email match. Name match being required on top, if
+ // specified.
+ if (
+ (nameMatched || !searchAddress.name) &&
+ searchAddress.email &&
+ address.email &&
+ address.email == searchAddress.email
+ ) {
+ return true;
+ }
+
+ // If address match failed, name match may only be true if no
+ // email has been specified.
+ if (!searchAddress.email && nameMatched) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ /**
+ * Substring match on name and exact match on email. If searchTerm
+ * includes multiple addresses, all of them must match.
+ *
+ * @returns A boolean indicating if search was successful.
+ */
+ const isAddressMatch = (searchTerm, addressObjects) => {
+ let searchAddresses =
+ MailServices.headerParser.makeFromDisplayAddress(searchTerm);
+ if (!searchAddresses || searchAddresses.length == 0) {
+ return false;
+ }
+
+ // Prepare addresses.
+ let addresses = [];
+ for (let addressObject of addressObjects) {
+ let decodedAddressString = addressObject.doRfc2047
+ ? jsmime.headerparser.decodeRFC2047Words(addressObject.addr)
+ : addressObject.addr;
+ for (let address of MailServices.headerParser.makeFromDisplayAddress(
+ decodedAddressString
+ )) {
+ addresses.push(prepareAddress(address));
+ }
+ }
+ if (addresses.length == 0) {
+ return false;
+ }
+
+ let success = false;
+ for (let searchAddress of searchAddresses) {
+ // Exit early if this search was not successfully, but all search
+ // addresses have to be matched.
+ if (
+ !searchInMultipleAddresses(
+ prepareAddress(searchAddress),
+ addresses
+ )
+ ) {
+ return false;
+ }
+ success = true;
+ }
+
+ return success;
+ };
+
+ const checkSearchCriteria = async (folder, msg) => {
+ // Check date ranges.
+ if (
+ queryInfo.fromDate !== null &&
+ msg.dateInSeconds * 1000 < queryInfo.fromDate.getTime()
+ ) {
+ return false;
+ }
+ if (
+ queryInfo.toDate !== null &&
+ msg.dateInSeconds * 1000 > queryInfo.toDate.getTime()
+ ) {
+ return false;
+ }
+
+ // Check headerMessageId.
+ if (
+ queryInfo.headerMessageId &&
+ msg.messageId != queryInfo.headerMessageId
+ ) {
+ return false;
+ }
+
+ // Check unread.
+ if (queryInfo.unread !== null && msg.isRead != !queryInfo.unread) {
+ return false;
+ }
+
+ // Check flagged.
+ if (
+ queryInfo.flagged !== null &&
+ msg.isFlagged != queryInfo.flagged
+ ) {
+ return false;
+ }
+
+ // Check subject (substring match).
+ if (
+ queryInfo.subject &&
+ !msg.mime2DecodedSubject.includes(queryInfo.subject)
+ ) {
+ return false;
+ }
+
+ // Check tags.
+ if (requiredTags || forbiddenTags) {
+ let messageTags = msg.getStringProperty("keywords").split(" ");
+ if (requiredTags.length > 0) {
+ if (
+ queryInfo.tags.mode == "all" &&
+ !requiredTags.every(tag => messageTags.includes(tag))
+ ) {
+ return false;
+ }
+ if (
+ queryInfo.tags.mode == "any" &&
+ !requiredTags.some(tag => messageTags.includes(tag))
+ ) {
+ return false;
+ }
+ }
+ if (forbiddenTags.length > 0) {
+ if (
+ queryInfo.tags.mode == "all" &&
+ forbiddenTags.every(tag => messageTags.includes(tag))
+ ) {
+ return false;
+ }
+ if (
+ queryInfo.tags.mode == "any" &&
+ forbiddenTags.some(tag => messageTags.includes(tag))
+ ) {
+ return false;
+ }
+ }
+ }
+
+ // Check toMe (case insensitive email address match).
+ if (queryInfo.toMe !== null) {
+ let recipients = [].concat(
+ composeFields.splitRecipients(msg.recipients, true),
+ composeFields.splitRecipients(msg.ccList, true),
+ composeFields.splitRecipients(msg.bccList, true)
+ );
+
+ if (
+ queryInfo.toMe !=
+ recipients.some(email =>
+ identities.includes(email.toLocaleLowerCase())
+ )
+ ) {
+ return false;
+ }
+ }
+
+ // Check fromMe (case insensitive email address match).
+ if (queryInfo.fromMe !== null) {
+ let authors = composeFields.splitRecipients(
+ msg.mime2DecodedAuthor,
+ true
+ );
+ if (
+ queryInfo.fromMe !=
+ authors.some(email =>
+ identities.includes(email.toLocaleLowerCase())
+ )
+ ) {
+ return false;
+ }
+ }
+
+ // Check author.
+ if (
+ queryInfo.author &&
+ !isAddressMatch(queryInfo.author, [
+ { addr: msg.mime2DecodedAuthor, doRfc2047: false },
+ ])
+ ) {
+ return false;
+ }
+
+ // Check recipients.
+ if (
+ queryInfo.recipients &&
+ !isAddressMatch(queryInfo.recipients, [
+ { addr: msg.mime2DecodedRecipients, doRfc2047: false },
+ { addr: msg.ccList, doRfc2047: true },
+ { addr: msg.bccList, doRfc2047: true },
+ ])
+ ) {
+ return false;
+ }
+
+ // Check if fullText is already partially fulfilled.
+ let fullTextBodySearchNeeded = false;
+ if (queryInfo.fullText) {
+ let subjectMatches = msg.mime2DecodedSubject.includes(
+ queryInfo.fullText
+ );
+ let authorMatches = msg.mime2DecodedAuthor.includes(
+ queryInfo.fullText
+ );
+ fullTextBodySearchNeeded = !(subjectMatches || authorMatches);
+ }
+
+ // Check body.
+ if (queryInfo.body || fullTextBodySearchNeeded) {
+ let mimeMsg = await getMimeMessage(msg);
+ if (
+ queryInfo.body &&
+ !includesContent(folder, [mimeMsg], queryInfo.body)
+ ) {
+ return false;
+ }
+ if (
+ fullTextBodySearchNeeded &&
+ !includesContent(folder, [mimeMsg], queryInfo.fullText)
+ ) {
+ return false;
+ }
+ }
+
+ // Check attachments.
+ if (queryInfo.attachment != null) {
+ let attachments = await getAttachments(
+ msg,
+ /* includeNestedAttachments */ true
+ );
+ return !!attachments.length == queryInfo.attachment;
+ }
+
+ return true;
+ };
+
+ const searchMessages = async (
+ folder,
+ messageList,
+ includeSubFolders = false
+ ) => {
+ let messages = null;
+ try {
+ messages = folder.messages;
+ } catch (e) {
+ /* Some folders fail on message query, instead of returning empty */
+ }
+
+ if (messages) {
+ for (let msg of [...messages]) {
+ if (await checkSearchCriteria(folder, msg)) {
+ messageList.add(msg);
+ }
+ }
+ }
+
+ if (includeSubFolders) {
+ for (let subFolder of folder.subFolders) {
+ await searchMessages(subFolder, messageList, true);
+ }
+ }
+ };
+
+ const searchFolders = async (
+ folders,
+ messageList,
+ includeSubFolders = false
+ ) => {
+ for (let folder of folders) {
+ await searchMessages(folder, messageList, includeSubFolders);
+ }
+ return messageList.done();
+ };
+
+ // Prepare case insensitive me filtering.
+ let identities;
+ if (queryInfo.toMe !== null || queryInfo.fromMe !== null) {
+ identities = MailServices.accounts.allIdentities.map(i =>
+ i.email.toLocaleLowerCase()
+ );
+ }
+
+ // Prepare tag filtering.
+ let requiredTags;
+ let forbiddenTags;
+ if (queryInfo.tags) {
+ let availableTags = MailServices.tags.getAllTags();
+ requiredTags = availableTags.filter(
+ tag =>
+ tag.key in queryInfo.tags.tags && queryInfo.tags.tags[tag.key]
+ );
+ forbiddenTags = availableTags.filter(
+ tag =>
+ tag.key in queryInfo.tags.tags && !queryInfo.tags.tags[tag.key]
+ );
+ // If non-existing tags have been required, return immediately with
+ // an empty message list.
+ if (
+ requiredTags.length === 0 &&
+ Object.values(queryInfo.tags.tags).filter(v => v).length > 0
+ ) {
+ return messageListTracker.startList([], context.extension);
+ }
+ requiredTags = requiredTags.map(tag => tag.key);
+ forbiddenTags = forbiddenTags.map(tag => tag.key);
+ }
+
+ // Limit search to a given folder, or search all folders.
+ let folders = [];
+ let includeSubFolders = false;
+ if (queryInfo.folder) {
+ includeSubFolders = !!queryInfo.includeSubFolders;
+ if (!context.extension.hasPermission("accountsRead")) {
+ throw new ExtensionError(
+ 'Querying by folder requires the "accountsRead" permission'
+ );
+ }
+ let folder = MailServices.folderLookup.getFolderForURL(
+ folderPathToURI(queryInfo.folder.accountId, queryInfo.folder.path)
+ );
+ if (!folder) {
+ throw new ExtensionError(
+ `Folder not found: ${queryInfo.folder.path}`
+ );
+ }
+ folders.push(folder);
+ } else {
+ includeSubFolders = true;
+ for (let account of MailServices.accounts.accounts) {
+ folders.push(account.incomingServer.rootFolder);
+ }
+ }
+
+ // The searchFolders() function searches the provided folders for
+ // messages matching the query and adds results to the messageList. It
+ // is an asynchronous function, but it is not awaited here. Instead,
+ // messageListTracker.getNextPage() returns a Promise, which will
+ // fulfill after enough messages for a full page have been added.
+ let messageList = messageListTracker.createList(context.extension);
+ searchFolders(folders, messageList, includeSubFolders);
+ return messageListTracker.getNextPage(messageList);
+ },
+ async update(messageId, newProperties) {
+ try {
+ let msgHdr = messageTracker.getMessage(messageId);
+ if (!msgHdr) {
+ throw new ExtensionError(`Message not found: ${messageId}.`);
+ }
+ if (!msgHdr.folder) {
+ throw new ExtensionError(
+ `Operation not permitted for external messages`
+ );
+ }
+
+ let msgs = [msgHdr];
+ if (newProperties.read !== null) {
+ msgHdr.folder.markMessagesRead(msgs, newProperties.read);
+ }
+ if (newProperties.flagged !== null) {
+ msgHdr.folder.markMessagesFlagged(msgs, newProperties.flagged);
+ }
+ if (newProperties.junk !== null) {
+ let score = newProperties.junk
+ ? Ci.nsIJunkMailPlugin.IS_SPAM_SCORE
+ : Ci.nsIJunkMailPlugin.IS_HAM_SCORE;
+ msgHdr.folder.setJunkScoreForMessages(msgs, score);
+ // nsIFolderListener::OnFolderEvent is notified about changes through
+ // setJunkScoreForMessages(), but does not provide the actual message.
+ // nsIMsgFolderListener::msgsJunkStatusChanged is notified only by
+ // nsMsgDBView::ApplyCommandToIndices(). Since it only works on
+ // selected messages, we cannot use it here.
+ // Notify msgsJunkStatusChanged() manually.
+ MailServices.mfn.notifyMsgsJunkStatusChanged(msgs);
+ }
+ if (Array.isArray(newProperties.tags)) {
+ let currentTags = msgHdr.getStringProperty("keywords").split(" ");
+
+ for (let { key: tagKey } of MailServices.tags.getAllTags()) {
+ if (newProperties.tags.includes(tagKey)) {
+ if (!currentTags.includes(tagKey)) {
+ msgHdr.folder.addKeywordsToMessages(msgs, tagKey);
+ }
+ } else if (currentTags.includes(tagKey)) {
+ msgHdr.folder.removeKeywordsFromMessages(msgs, tagKey);
+ }
+ }
+ }
+ } catch (ex) {
+ console.error(ex);
+ throw new ExtensionError(`Error updating message: ${ex.message}`);
+ }
+ },
+ async move(messageIds, destination) {
+ return moveOrCopyMessages(messageIds, destination, true);
+ },
+ async copy(messageIds, destination) {
+ return moveOrCopyMessages(messageIds, destination, false);
+ },
+ async delete(messageIds, skipTrash) {
+ try {
+ let promises = [];
+ let folderMap = collectMessagesInFolders(messageIds);
+ for (let [sourceFolder, msgHeaderSet] of folderMap.entries()) {
+ if (!sourceFolder) {
+ throw new ExtensionError(
+ `Operation not permitted for external messages`
+ );
+ }
+ if (!sourceFolder.canDeleteMessages) {
+ throw new ExtensionError(
+ `Messages in "${sourceFolder.prettyName}" cannot be deleted`
+ );
+ }
+ promises.push(
+ new Promise((resolve, reject) => {
+ sourceFolder.deleteMessages(
+ [...msgHeaderSet],
+ /* msgWindow */ null,
+ /* deleteStorage */ skipTrash,
+ /* isMove */ false,
+ {
+ OnStartCopy() {},
+ OnProgress(progress, progressMax) {},
+ SetMessageKey(key) {},
+ GetMessageId(messageId) {},
+ OnStopCopy(status) {
+ if (status == Cr.NS_OK) {
+ resolve();
+ } else {
+ reject(status);
+ }
+ },
+ },
+ /* allowUndo */ true
+ );
+ })
+ );
+ }
+ await Promise.all(promises);
+ } catch (ex) {
+ console.error(ex);
+ throw new ExtensionError(`Error deleting message: ${ex.message}`);
+ }
+ },
+ async import(file, { accountId, path }, properties) {
+ if (
+ !context.extension.hasPermission("accountsRead") ||
+ !context.extension.hasPermission("messagesImport")
+ ) {
+ throw new ExtensionError(
+ `Using messages.import() requires the "accountsRead" and the "messagesImport" permission`
+ );
+ }
+ let destinationURI = folderPathToURI(accountId, path);
+ let destinationFolder =
+ MailServices.folderLookup.getFolderForURL(destinationURI);
+ if (!destinationFolder) {
+ throw new ExtensionError(`Folder not found: ${path}`);
+ }
+ if (!["none", "pop3"].includes(destinationFolder.server.type)) {
+ throw new ExtensionError(
+ `browser.messenger.import() is not supported for ${destinationFolder.server.type} accounts`
+ );
+ }
+ try {
+ let tempFile = await getRealFileForFile(file);
+ let msgHeader = await new Promise((resolve, reject) => {
+ let newKey = null;
+ let msgHdrs = new Map();
+
+ let folderListener = {
+ onMessageAdded(parentItem, msgHdr) {
+ if (destinationFolder.URI != msgHdr.folder.URI) {
+ return;
+ }
+ let key = msgHdr.messageKey;
+ msgHdrs.set(key, msgHdr);
+ if (msgHdrs.has(newKey)) {
+ finish(msgHdrs.get(newKey));
+ }
+ },
+ onFolderAdded(parent, child) {},
+ };
+
+ // Note: Currently this API is not supported for IMAP. Once this gets added (Bug 1787104),
+ // please note that the MailServices.mfn.addListener will fire only when the IMAP message
+ // is visibly shown in the UI, while MailServices.mailSession.AddFolderListener fires as
+ // soon as it has been added to the database .
+ MailServices.mailSession.AddFolderListener(
+ folderListener,
+ Ci.nsIFolderListener.added
+ );
+
+ let finish = msgHdr => {
+ MailServices.mailSession.RemoveFolderListener(folderListener);
+ resolve(msgHdr);
+ };
+
+ let tags = "";
+ let flags = 0;
+ if (properties) {
+ if (properties.tags) {
+ let knownTags = MailServices.tags
+ .getAllTags()
+ .map(tag => tag.key);
+ tags = properties.tags
+ .filter(tag => knownTags.includes(tag))
+ .join(" ");
+ }
+ flags |= properties.new ? Ci.nsMsgMessageFlags.New : 0;
+ flags |= properties.read ? Ci.nsMsgMessageFlags.Read : 0;
+ flags |= properties.flagged ? Ci.nsMsgMessageFlags.Marked : 0;
+ }
+ MailServices.copy.copyFileMessage(
+ tempFile,
+ destinationFolder,
+ /* msgToReplace */ null,
+ /* isDraftOrTemplate */ false,
+ /* aMsgFlags */ flags,
+ /* aMsgKeywords */ tags,
+ {
+ OnStartCopy() {},
+ OnProgress(progress, progressMax) {},
+ SetMessageKey(aKey) {
+ /* Note: Not fired for offline IMAP. Add missing
+ * if (aCopyState) {
+ * ((nsImapMailCopyState*)aCopyState)->m_listener->SetMessageKey(fakeKey);
+ * }
+ * before firing the OnStopRunningUrl listener in
+ * nsImapService::OfflineAppendFromFile
+ */
+ newKey = aKey;
+ if (msgHdrs.has(newKey)) {
+ finish(msgHdrs.get(newKey));
+ }
+ },
+ GetMessageId(messageId) {},
+ OnStopCopy(status) {
+ if (status == Cr.NS_OK) {
+ if (newKey && msgHdrs.has(newKey)) {
+ finish(msgHdrs.get(newKey));
+ }
+ } else {
+ reject(status);
+ }
+ },
+ },
+ /* msgWindow */ null
+ );
+ });
+
+ // Do not wait till the temp file is removed on app shutdown. However, skip deletion if
+ // the provided DOM File was already linked to a real file.
+ if (!file.mozFullPath) {
+ await IOUtils.remove(tempFile.path);
+ }
+ return convertMessage(msgHeader, context.extension);
+ } catch (ex) {
+ console.error(ex);
+ throw new ExtensionError(`Error importing message: ${ex.message}`);
+ }
+ },
+ async archive(messageIds) {
+ try {
+ let messages = [];
+ let folderMap = collectMessagesInFolders(messageIds);
+ for (let [sourceFolder, msgHeaderSet] of folderMap.entries()) {
+ if (!sourceFolder) {
+ throw new ExtensionError(
+ `Operation not permitted for external messages`
+ );
+ }
+ messages.push(...msgHeaderSet);
+ }
+ await new Promise(resolve => {
+ let archiver = new MessageArchiver();
+ archiver.oncomplete = resolve;
+ archiver.archiveMessages(messages);
+ });
+ } catch (ex) {
+ console.error(ex);
+ throw new ExtensionError(`Error archiving message: ${ex.message}`);
+ }
+ },
+ async listTags() {
+ return MailServices.tags
+ .getAllTags()
+ .map(({ key, tag, color, ordinal }) => {
+ return {
+ key,
+ tag,
+ color,
+ ordinal,
+ };
+ });
+ },
+ async createTag(key, tag, color) {
+ let tags = MailServices.tags.getAllTags();
+ key = key.toLowerCase();
+ if (tags.find(t => t.key == key)) {
+ throw new ExtensionError(`Specified key already exists: ${key}`);
+ }
+ if (tags.find(t => t.tag == tag)) {
+ throw new ExtensionError(`Specified tag already exists: ${tag}`);
+ }
+ MailServices.tags.addTagForKey(key, tag, color, "");
+ },
+ async updateTag(key, updateProperties) {
+ let tags = MailServices.tags.getAllTags();
+ key = key.toLowerCase();
+ let tag = tags.find(t => t.key == key);
+ if (!tag) {
+ throw new ExtensionError(`Specified key does not exist: ${key}`);
+ }
+ if (updateProperties.color && tag.color != updateProperties.color) {
+ MailServices.tags.setColorForKey(key, updateProperties.color);
+ }
+ if (updateProperties.tag && tag.tag != updateProperties.tag) {
+ // Don't let the user edit a tag to the name of another existing tag.
+ if (tags.find(t => t.tag == updateProperties.tag)) {
+ throw new ExtensionError(
+ `Specified tag already exists: ${updateProperties.tag}`
+ );
+ }
+ MailServices.tags.setTagForKey(key, updateProperties.tag);
+ }
+ },
+ async deleteTag(key) {
+ let tags = MailServices.tags.getAllTags();
+ key = key.toLowerCase();
+ if (!tags.find(t => t.key == key)) {
+ throw new ExtensionError(`Specified key does not exist: ${key}`);
+ }
+ MailServices.tags.deleteKey(key);
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-sessions.js b/comm/mail/components/extensions/parent/ext-sessions.js
new file mode 100644
index 0000000000..3abe652fe3
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-sessions.js
@@ -0,0 +1,62 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionSupport } = ChromeUtils.import(
+ "resource:///modules/ExtensionSupport.jsm"
+);
+var { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+var { makeWidgetId } = ExtensionCommon;
+
+function getSessionData(tabId, extension) {
+ let nativeTab = tabTracker.getTab(tabId);
+ let widgetId = makeWidgetId(extension.id);
+
+ if (!nativeTab._ext.extensionSession) {
+ nativeTab._ext.extensionSession = {};
+ }
+ if (!nativeTab._ext.extensionSession[`${widgetId}`]) {
+ nativeTab._ext.extensionSession[`${widgetId}`] = {};
+ }
+ return nativeTab._ext.extensionSession[`${widgetId}`];
+}
+
+this.sessions = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ sessions: {
+ setTabValue(tabId, key, value) {
+ let sessionData = getSessionData(tabId, context.extension);
+ sessionData[key] = value;
+ },
+ getTabValue(tabId, key) {
+ let sessionData = getSessionData(tabId, context.extension);
+ return sessionData[key];
+ },
+ removeTabValue(tabId, key) {
+ let sessionData = getSessionData(tabId, context.extension);
+ delete sessionData[key];
+ },
+ },
+ };
+ }
+
+ static onUninstall(extensionId) {
+ // Remove session data.
+ let widgetId = makeWidgetId(extensionId);
+ for (let window of Services.wm.getEnumerator("mail:3pane")) {
+ for (let tabInfo of window.gTabmail.tabInfo) {
+ if (
+ tabInfo._ext.extensionSession &&
+ tabInfo._ext.extensionSession[`${widgetId}`]
+ ) {
+ delete tabInfo._ext.extensionSession[`${widgetId}`];
+ }
+ }
+ }
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-spaces.js b/comm/mail/components/extensions/parent/ext-spaces.js
new file mode 100644
index 0000000000..3f2ade0404
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-spaces.js
@@ -0,0 +1,364 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionSupport } = ChromeUtils.import(
+ "resource:///modules/ExtensionSupport.jsm"
+);
+var { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "getIconData",
+ "resource:///modules/ExtensionToolbarButtons.jsm"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["InspectorUtils"]);
+
+var windowURLs = ["chrome://messenger/content/messenger.xhtml"];
+
+/**
+ * Return the paths to the 16px and 32px icons defined in the manifest of this
+ * extension, if any.
+ *
+ * @param {ExtensionData} extension - the extension to retrieve the path object for
+ */
+function getManifestIcons(extension) {
+ if (extension.manifest.icons) {
+ let { icon: icon16 } = ExtensionParent.IconDetails.getPreferredIcon(
+ extension.manifest.icons,
+ extension,
+ 16
+ );
+ let { icon: icon32 } = ExtensionParent.IconDetails.getPreferredIcon(
+ extension.manifest.icons,
+ extension,
+ 32
+ );
+ return {
+ 16: extension.baseURI.resolve(icon16),
+ 32: extension.baseURI.resolve(icon32),
+ };
+ }
+ return null;
+}
+
+/**
+ * Convert WebExtension SpaceButtonProperties into a NativeButtonProperties
+ * object required by the gSpacesToolbar.* functions.
+ *
+ * @param {SpaceData} spaceData - @see mail/components/extensions/parent/ext-mail.js
+ * @returns {NativeButtonProperties} - @see mail/base/content/spacesToolbar.js
+ */
+function getNativeButtonProperties({
+ extension,
+ defaultUrl,
+ buttonProperties,
+}) {
+ const normalizeColor = color => {
+ if (typeof color == "string") {
+ let col = InspectorUtils.colorToRGBA(color);
+ if (!col) {
+ throw new ExtensionError(`Invalid color value: "${color}"`);
+ }
+ return [col.r, col.g, col.b, Math.round(col.a * 255)];
+ }
+ return color;
+ };
+
+ let hasThemeIcons =
+ buttonProperties.themeIcons && buttonProperties.themeIcons.length > 0;
+
+ // If themeIcons have been defined, ignore manifestIcons as fallback and use
+ // themeIcons for the default theme as well, following the behavior of
+ // WebExtension action buttons.
+ let fallbackManifestIcons = hasThemeIcons
+ ? null
+ : getManifestIcons(extension);
+
+ // Use _normalize() to bypass cache.
+ let icons = ExtensionParent.IconDetails._normalize(
+ {
+ path: buttonProperties.defaultIcons || fallbackManifestIcons,
+ themeIcons: hasThemeIcons ? buttonProperties.themeIcons : null,
+ },
+ extension
+ );
+ let iconStyles = new Map(getIconData(icons, extension).style);
+
+ let badgeStyles = new Map();
+ let bgColor = normalizeColor(buttonProperties.badgeBackgroundColor);
+ if (bgColor) {
+ badgeStyles.set(
+ "--spaces-button-badge-bg-color",
+ `rgba(${bgColor[0]}, ${bgColor[1]}, ${bgColor[2]}, ${bgColor[3] / 255})`
+ );
+ }
+
+ return {
+ title: buttonProperties.title || extension.name,
+ url: defaultUrl,
+ badgeText: buttonProperties.badgeText,
+ badgeStyles,
+ iconStyles,
+ };
+}
+
+ExtensionSupport.registerWindowListener("ext-spaces", {
+ chromeURLs: windowURLs,
+ onLoadWindow: async window => {
+ await new Promise(resolve => {
+ if (window.gSpacesToolbar.isLoaded) {
+ resolve();
+ } else {
+ window.addEventListener("spaces-toolbar-ready", resolve, {
+ once: true,
+ });
+ }
+ });
+ // Add buttons of all extension spaces to the toolbar of each newly opened
+ // normal window.
+ for (let spaceData of spaceTracker.getAll()) {
+ if (!spaceData.extension) {
+ continue;
+ }
+ let nativeButtonProperties = getNativeButtonProperties(spaceData);
+ await window.gSpacesToolbar.createToolbarButton(
+ spaceData.spaceButtonId,
+ nativeButtonProperties
+ );
+ }
+ },
+});
+
+this.spaces = class extends ExtensionAPI {
+ /**
+ * Match a WebExtension Space object against the provided queryInfo.
+ *
+ * @param {Space} space - @see mail/components/extensions/schemas/spaces.json
+ * @param {QueryInfo} queryInfo - @see mail/components/extensions/schemas/spaces.json
+ * @returns {boolean}
+ */
+ matchSpace(space, queryInfo) {
+ if (queryInfo.id != null && space.id != queryInfo.id) {
+ return false;
+ }
+ if (queryInfo.name != null && space.name != queryInfo.name) {
+ return false;
+ }
+ if (queryInfo.isBuiltIn != null && space.isBuiltIn != queryInfo.isBuiltIn) {
+ return false;
+ }
+ if (
+ queryInfo.isSelfOwned != null &&
+ space.isSelfOwned != queryInfo.isSelfOwned
+ ) {
+ return false;
+ }
+ if (
+ queryInfo.extensionId != null &&
+ space.extensionId != queryInfo.extensionId
+ ) {
+ return false;
+ }
+ return true;
+ }
+
+ async onShutdown(isAppShutdown) {
+ if (isAppShutdown) {
+ return;
+ }
+
+ let extensionId = this.extension.id;
+ for (let spaceData of spaceTracker.getAll()) {
+ if (spaceData.extension?.id != extensionId) {
+ continue;
+ }
+ for (let window of ExtensionSupport.openWindows) {
+ if (windowURLs.includes(window.location.href)) {
+ await window.gSpacesToolbar.removeToolbarButton(
+ spaceData.spaceButtonId
+ );
+ }
+ }
+ spaceTracker.remove(spaceData);
+ }
+ }
+
+ getAPI(context) {
+ let { tabManager } = context.extension;
+ let self = this;
+
+ return {
+ spaces: {
+ async create(name, defaultUrl, buttonProperties) {
+ if (spaceTracker.fromSpaceName(name, context.extension)) {
+ throw new ExtensionError(
+ `Failed to create space with name ${name}: Space already exists for this extension.`
+ );
+ }
+
+ defaultUrl = context.uri.resolve(defaultUrl);
+ if (!/((^https:)|(^http:)|(^moz-extension:))/i.test(defaultUrl)) {
+ throw new ExtensionError(
+ `Failed to create space with name ${name}: Invalid default url.`
+ );
+ }
+
+ try {
+ let spaceData = await spaceTracker.create(
+ name,
+ defaultUrl,
+ buttonProperties,
+ context.extension
+ );
+
+ let nativeButtonProperties = getNativeButtonProperties(spaceData);
+ for (let window of ExtensionSupport.openWindows) {
+ if (windowURLs.includes(window.location.href)) {
+ await window.gSpacesToolbar.createToolbarButton(
+ spaceData.spaceButtonId,
+ nativeButtonProperties
+ );
+ }
+ }
+
+ return spaceTracker.convert(spaceData, context.extension);
+ } catch (error) {
+ throw new ExtensionError(
+ `Failed to create space with name ${name}: ${error}`
+ );
+ }
+ },
+ async remove(spaceId) {
+ let spaceData = spaceTracker.fromSpaceId(spaceId);
+ if (!spaceData) {
+ throw new ExtensionError(
+ `Failed to remove space with id ${spaceId}: Unknown id.`
+ );
+ }
+ if (spaceData.extension?.id != context.extension.id) {
+ throw new ExtensionError(
+ `Failed to remove space with id ${spaceId}: Space does not belong to this extension.`
+ );
+ }
+
+ try {
+ for (let window of ExtensionSupport.openWindows) {
+ if (windowURLs.includes(window.location.href)) {
+ await window.gSpacesToolbar.removeToolbarButton(
+ spaceData.spaceButtonId
+ );
+ }
+ }
+ spaceTracker.remove(spaceData);
+ } catch (ex) {
+ throw new ExtensionError(
+ `Failed to remove space with id ${spaceId}: ${ex.message}`
+ );
+ }
+ },
+ async update(spaceId, updatedDefaultUrl, updatedButtonProperties) {
+ let spaceData = spaceTracker.fromSpaceId(spaceId);
+ if (!spaceData) {
+ throw new ExtensionError(
+ `Failed to update space with id ${spaceId}: Unknown id.`
+ );
+ }
+ if (spaceData.extension?.id != context.extension.id) {
+ throw new ExtensionError(
+ `Failed to update space with id ${spaceId}: Space does not belong to this extension.`
+ );
+ }
+
+ let changes = false;
+ if (updatedDefaultUrl) {
+ updatedDefaultUrl = context.uri.resolve(updatedDefaultUrl);
+ if (
+ !/((^https:)|(^http:)|(^moz-extension:))/i.test(updatedDefaultUrl)
+ ) {
+ throw new ExtensionError(
+ `Failed to update space with id ${spaceId}: Invalid default url.`
+ );
+ }
+ spaceData.defaultUrl = updatedDefaultUrl;
+ changes = true;
+ }
+
+ if (updatedButtonProperties) {
+ for (let [key, value] of Object.entries(updatedButtonProperties)) {
+ if (value != null) {
+ spaceData.buttonProperties[key] = value;
+ changes = true;
+ }
+ }
+ }
+
+ if (changes) {
+ let nativeButtonProperties = getNativeButtonProperties(spaceData);
+ try {
+ for (let window of ExtensionSupport.openWindows) {
+ if (windowURLs.includes(window.location.href)) {
+ await window.gSpacesToolbar.updateToolbarButton(
+ spaceData.spaceButtonId,
+ nativeButtonProperties
+ );
+ }
+ }
+ spaceTracker.update(spaceData);
+ } catch (error) {
+ throw new ExtensionError(
+ `Failed to update space with id ${spaceId}: ${error}`
+ );
+ }
+ }
+ },
+ async open(spaceId, windowId) {
+ let spaceData = spaceTracker.fromSpaceId(spaceId);
+ if (!spaceData) {
+ throw new ExtensionError(
+ `Failed to open space with id ${spaceId}: Unknown id.`
+ );
+ }
+
+ let window = await getNormalWindowReady(context, windowId);
+ let space = window.gSpacesToolbar.spaces.find(
+ space => space.button.id == spaceData.spaceButtonId
+ );
+
+ let tabmail = window.document.getElementById("tabmail");
+ let currentTab = tabmail.selectedTab;
+ let nativeTabInfo = window.gSpacesToolbar.openSpace(tabmail, space);
+ return tabManager.convert(nativeTabInfo, currentTab);
+ },
+ async get(spaceId) {
+ let spaceData = spaceTracker.fromSpaceId(spaceId);
+ if (!spaceData) {
+ throw new ExtensionError(
+ `Failed to get space with id ${spaceId}: Unknown id.`
+ );
+ }
+ return spaceTracker.convert(spaceData, context.extension);
+ },
+ async query(queryInfo) {
+ let allSpaceData = [...spaceTracker.getAll()];
+ return allSpaceData
+ .map(spaceData =>
+ spaceTracker.convert(spaceData, context.extension)
+ )
+ .filter(space => self.matchSpace(space, queryInfo));
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-spacesToolbar.js b/comm/mail/components/extensions/parent/ext-spacesToolbar.js
new file mode 100644
index 0000000000..1a42aa0a6e
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-spacesToolbar.js
@@ -0,0 +1,308 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ExtensionSupport } = ChromeUtils.import(
+ "resource:///modules/ExtensionSupport.jsm"
+);
+var { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "getIconData",
+ "resource:///modules/ExtensionToolbarButtons.jsm"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["InspectorUtils"]);
+
+var { makeWidgetId } = ExtensionCommon;
+
+var windowURLs = ["chrome://messenger/content/messenger.xhtml"];
+
+/**
+ * Return the paths to the 16px and 32px icons defined in the manifest of this
+ * extension, if any.
+ *
+ * @param {ExtensionData} extension - the extension to retrieve the path object for
+ */
+function getManifestIcons(extension) {
+ if (extension.manifest.icons) {
+ let { icon: icon16 } = ExtensionParent.IconDetails.getPreferredIcon(
+ extension.manifest.icons,
+ extension,
+ 16
+ );
+ let { icon: icon32 } = ExtensionParent.IconDetails.getPreferredIcon(
+ extension.manifest.icons,
+ extension,
+ 32
+ );
+ return {
+ 16: extension.baseURI.resolve(icon16),
+ 32: extension.baseURI.resolve(icon32),
+ };
+ }
+ return null;
+}
+
+/**
+ * Convert WebExtension SpaceButtonProperties into a NativeButtonProperties
+ * object required by the gSpacesToolbar.* functions.
+ *
+ * @param {SpaceData} spaceData - @see mail/components/extensions/parent/ext-mail.js
+ * @returns {NativeButtonProperties} - @see mail/base/content/spacesToolbar.js
+ */
+function convertProperties({ extension, buttonProperties }) {
+ const normalizeColor = color => {
+ if (typeof color == "string") {
+ let col = InspectorUtils.colorToRGBA(color);
+ if (!col) {
+ throw new ExtensionError(`Invalid color value: "${color}"`);
+ }
+ return [col.r, col.g, col.b, Math.round(col.a * 255)];
+ }
+ return color;
+ };
+
+ let hasThemeIcons =
+ buttonProperties.themeIcons && buttonProperties.themeIcons.length > 0;
+
+ // If themeIcons have been defined, ignore manifestIcons as fallback and use
+ // themeIcons for the default theme as well, following the behavior of
+ // WebExtension action buttons.
+ let fallbackManifestIcons = hasThemeIcons
+ ? null
+ : getManifestIcons(extension);
+
+ // Use _normalize() to bypass cache.
+ let icons = ExtensionParent.IconDetails._normalize(
+ {
+ path: buttonProperties.defaultIcons || fallbackManifestIcons,
+ themeIcons: hasThemeIcons ? buttonProperties.themeIcons : null,
+ },
+ extension
+ );
+ let iconStyles = new Map(getIconData(icons, extension).style);
+
+ let badgeStyles = new Map();
+ let bgColor = normalizeColor(buttonProperties.badgeBackgroundColor);
+ if (bgColor) {
+ badgeStyles.set(
+ "--spaces-button-badge-bg-color",
+ `rgba(${bgColor[0]}, ${bgColor[1]}, ${bgColor[2]}, ${bgColor[3] / 255})`
+ );
+ }
+
+ return {
+ title: buttonProperties.title || extension.name,
+ url: buttonProperties.url,
+ badgeText: buttonProperties.badgeText,
+ badgeStyles,
+ iconStyles,
+ };
+}
+
+ExtensionSupport.registerWindowListener("ext-spacesToolbar", {
+ chromeURLs: windowURLs,
+ onLoadWindow: async window => {
+ await new Promise(resolve => {
+ if (window.gSpacesToolbar.isLoaded) {
+ resolve();
+ } else {
+ window.addEventListener("spaces-toolbar-ready", resolve, {
+ once: true,
+ });
+ }
+ });
+ // Add buttons of all extension spaces to the toolbar of each newly opened
+ // normal window.
+ for (let spaceData of spaceTracker.getAll()) {
+ if (!spaceData.extension) {
+ continue;
+ }
+ let nativeButtonProperties = convertProperties(spaceData);
+ await window.gSpacesToolbar.createToolbarButton(
+ spaceData.spaceButtonId,
+ nativeButtonProperties
+ );
+ }
+ },
+});
+
+this.spacesToolbar = class extends ExtensionAPI {
+ async onShutdown(isAppShutdown) {
+ if (isAppShutdown) {
+ return;
+ }
+
+ let extensionId = this.extension.id;
+ for (let spaceData of spaceTracker.getAll()) {
+ if (spaceData.extension?.id != extensionId) {
+ continue;
+ }
+ for (let window of ExtensionSupport.openWindows) {
+ if (windowURLs.includes(window.location.href)) {
+ await window.gSpacesToolbar.removeToolbarButton(
+ spaceData.spaceButtonId
+ );
+ }
+ }
+ spaceTracker.remove(spaceData);
+ }
+ }
+
+ getAPI(context) {
+ this.widgetId = makeWidgetId(context.extension.id);
+ let { tabManager } = context.extension;
+
+ return {
+ spacesToolbar: {
+ async addButton(name, properties) {
+ if (properties.url) {
+ properties.url = context.uri.resolve(properties.url);
+ }
+ let [protocol] = (properties.url || "").split("://");
+ if (
+ !protocol ||
+ !["https", "http", "moz-extension"].includes(protocol)
+ ) {
+ throw new ExtensionError(
+ `Failed to add button to the spaces toolbar: Invalid url.`
+ );
+ }
+
+ if (spaceTracker.fromSpaceName(name, context.extension)) {
+ throw new ExtensionError(
+ `Failed to add button to the spaces toolbar: The id ${name} is already used by this extension.`
+ );
+ }
+ try {
+ let spaceData = await spaceTracker.create(
+ name,
+ properties.url,
+ properties,
+ context.extension
+ );
+
+ let nativeButtonProperties = convertProperties(spaceData);
+ for (let window of ExtensionSupport.openWindows) {
+ if (windowURLs.includes(window.location.href)) {
+ await window.gSpacesToolbar.createToolbarButton(
+ spaceData.spaceButtonId,
+ nativeButtonProperties
+ );
+ }
+ }
+
+ return spaceData.spaceId;
+ } catch (error) {
+ throw new ExtensionError(
+ `Failed to add button to the spaces toolbar: ${error}`
+ );
+ }
+ },
+ async removeButton(name) {
+ let spaceData = spaceTracker.fromSpaceName(name, context.extension);
+ if (!spaceData) {
+ throw new ExtensionError(
+ `Failed to remove button from the spaces toolbar: A button with id ${name} does not exist for this extension.`
+ );
+ }
+ try {
+ for (let window of ExtensionSupport.openWindows) {
+ if (windowURLs.includes(window.location.href)) {
+ await window.gSpacesToolbar.removeToolbarButton(
+ spaceData.spaceButtonId
+ );
+ }
+ }
+ spaceTracker.remove(spaceData);
+ } catch (ex) {
+ throw new ExtensionError(
+ `Failed to remove button from the spaces toolbar: ${ex.message}`
+ );
+ }
+ },
+ async updateButton(name, updatedProperties) {
+ let spaceData = spaceTracker.fromSpaceName(name, context.extension);
+ if (!spaceData) {
+ throw new ExtensionError(
+ `Failed to update button in the spaces toolbar: A button with id ${name} does not exist for this extension.`
+ );
+ }
+
+ if (updatedProperties.url != null) {
+ updatedProperties.url = context.uri.resolve(updatedProperties.url);
+ let [protocol] = updatedProperties.url.split("://");
+ if (
+ !protocol ||
+ !["https", "http", "moz-extension"].includes(protocol)
+ ) {
+ throw new ExtensionError(
+ `Failed to update button in the spaces toolbar: Invalid url.`
+ );
+ }
+ }
+
+ let changes = false;
+ for (let [key, value] of Object.entries(updatedProperties)) {
+ if (value != null) {
+ if (key == "url") {
+ spaceData.defaultUrl = value;
+ }
+ spaceData.buttonProperties[key] = value;
+ changes = true;
+ }
+ }
+
+ if (changes) {
+ let nativeButtonProperties = convertProperties(spaceData);
+ try {
+ for (let window of ExtensionSupport.openWindows) {
+ if (windowURLs.includes(window.location.href)) {
+ await window.gSpacesToolbar.updateToolbarButton(
+ spaceData.spaceButtonId,
+ nativeButtonProperties
+ );
+ }
+ }
+ spaceTracker.update(spaceData);
+ } catch (error) {
+ throw new ExtensionError(
+ `Failed to update button in the spaces toolbar: ${error}`
+ );
+ }
+ }
+ },
+ async clickButton(name, windowId) {
+ let spaceData = spaceTracker.fromSpaceName(name, context.extension);
+ if (!spaceData) {
+ throw new ExtensionError(
+ `Failed to trigger a click on the spaces toolbar button: A button with id ${name} does not exist for this extension.`
+ );
+ }
+
+ let window = await getNormalWindowReady(context, windowId);
+ let space = window.gSpacesToolbar.spaces.find(
+ space => space.button.id == spaceData.spaceButtonId
+ );
+
+ let tabmail = window.document.getElementById("tabmail");
+ let currentTab = tabmail.selectedTab;
+ let nativeTabInfo = window.gSpacesToolbar.openSpace(tabmail, space);
+ return tabManager.convert(nativeTabInfo, currentTab);
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-tabs.js b/comm/mail/components/extensions/parent/ext-tabs.js
new file mode 100644
index 0000000000..6327743afa
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-tabs.js
@@ -0,0 +1,822 @@
+/* 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/. */
+
+ChromeUtils.defineESModuleGetters(this, {
+ PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs",
+});
+ChromeUtils.defineModuleGetter(
+ this,
+ "MailE10SUtils",
+ "resource:///modules/MailE10SUtils.jsm"
+);
+
+var { ExtensionError } = ExtensionUtils;
+
+/**
+ * A listener that allows waiting until tabs are fully loaded, e.g. off of about:blank.
+ */
+let tabListener = {
+ tabReadyInitialized: false,
+ tabReadyPromises: new WeakMap(),
+ initializingTabs: new WeakSet(),
+
+ /**
+ * Initialize the progress listener for tab ready changes.
+ */
+ initTabReady() {
+ if (!this.tabReadyInitialized) {
+ windowTracker.addListener("progress", this);
+
+ this.tabReadyInitialized = true;
+ }
+ },
+
+ /**
+ * Web Progress listener method for the location change.
+ *
+ * @param {Element} browser - The browser element that caused the change
+ * @param {nsIWebProgress} webProgress - The web progress for the location change
+ * @param {nsIRequest} request - The xpcom request for this change
+ * @param {nsIURI} locationURI - The target uri
+ * @param {Integer} flags - The web progress flags for this change
+ */
+ onLocationChange(browser, webProgress, request, locationURI, flags) {
+ if (webProgress && webProgress.isTopLevel) {
+ let window = browser.ownerGlobal.top;
+ let tabmail = window.document.getElementById("tabmail");
+ let nativeTabInfo = tabmail ? tabmail.getTabForBrowser(browser) : window;
+
+ // Now we are certain that the first page in the tab was loaded.
+ this.initializingTabs.delete(nativeTabInfo);
+
+ // browser.innerWindowID is now set, resolve the promises if any.
+ let deferred = this.tabReadyPromises.get(nativeTabInfo);
+ if (deferred) {
+ deferred.resolve(nativeTabInfo);
+ this.tabReadyPromises.delete(nativeTabInfo);
+ }
+ }
+ },
+
+ /**
+ * Promise that the given tab completes loading.
+ *
+ * @param {NativeTabInfo} nativeTabInfo - the tabInfo describing the tab
+ * @returns {Promise<NativeTabInfo>} - resolves when the tab completes loading
+ */
+ awaitTabReady(nativeTabInfo) {
+ let deferred = this.tabReadyPromises.get(nativeTabInfo);
+ if (!deferred) {
+ deferred = PromiseUtils.defer();
+ let browser = getTabBrowser(nativeTabInfo);
+ if (
+ !this.initializingTabs.has(nativeTabInfo) &&
+ (browser.innerWindowID ||
+ ["about:blank", "about:blank?compose"].includes(
+ browser.currentURI.spec
+ ))
+ ) {
+ deferred.resolve(nativeTabInfo);
+ } else {
+ this.initTabReady();
+ this.tabReadyPromises.set(nativeTabInfo, deferred);
+ }
+ }
+ return deferred.promise;
+ },
+};
+
+let hasWebHandlerApp = protocol => {
+ let protoInfo = Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .getProtocolHandlerInfo(protocol);
+ let appHandlers = protoInfo.possibleApplicationHandlers;
+ for (let i = 0; i < appHandlers.length; i++) {
+ let handler = appHandlers.queryElementAt(i, Ci.nsISupports);
+ if (handler instanceof Ci.nsIWebHandlerApp) {
+ return true;
+ }
+ }
+ return false;
+};
+
+// Attributes and properties used in the TabsUpdateFilterManager.
+const allAttrs = new Set(["favIconUrl", "title"]);
+const allProperties = new Set(["favIconUrl", "status", "title"]);
+const restricted = new Set(["url", "favIconUrl", "title"]);
+
+this.tabs = class extends ExtensionAPIPersistent {
+ onShutdown(isAppShutdown) {
+ if (isAppShutdown) {
+ return;
+ }
+ for (let window of Services.wm.getEnumerator("mail:3pane")) {
+ let tabmail = window.document.getElementById("tabmail");
+ for (let i = tabmail.tabInfo.length; i > 0; i--) {
+ let nativeTabInfo = tabmail.tabInfo[i - 1];
+ let uri = nativeTabInfo.browser?.browsingContext.currentURI;
+ if (
+ uri &&
+ uri.scheme == "moz-extension" &&
+ uri.host == this.extension.uuid
+ ) {
+ tabmail.closeTab(nativeTabInfo);
+ }
+ }
+ }
+ }
+
+ tabEventRegistrar({ tabEvent, listener }) {
+ let { extension } = this;
+ let { tabManager } = extension;
+ return ({ context, fire }) => {
+ let listener2 = async (eventName, event, ...args) => {
+ if (!tabManager.canAccessTab(event.nativeTab)) {
+ return;
+ }
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ listener({ context, fire, event }, ...args);
+ };
+ tabTracker.on(tabEvent, listener2);
+ return {
+ unregister() {
+ tabTracker.off(tabEvent, listener2);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ };
+ }
+
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called) (handled by tabEventRegistrar).
+
+ onActivated: this.tabEventRegistrar({
+ tabEvent: "tab-activated",
+ listener: ({ context, fire, event }) => {
+ let { tabId, windowId, previousTabId } = event;
+ fire.async({ tabId, windowId, previousTabId });
+ },
+ }),
+
+ onCreated: this.tabEventRegistrar({
+ tabEvent: "tab-created",
+ listener: ({ context, fire, event }) => {
+ let { extension } = this;
+ let { tabManager } = extension;
+ fire.async(tabManager.convert(event.nativeTabInfo, event.currentTab));
+ },
+ }),
+
+ onAttached: this.tabEventRegistrar({
+ tabEvent: "tab-attached",
+ listener: ({ context, fire, event }) => {
+ fire.async(event.tabId, {
+ newWindowId: event.newWindowId,
+ newPosition: event.newPosition,
+ });
+ },
+ }),
+
+ onDetached: this.tabEventRegistrar({
+ tabEvent: "tab-detached",
+ listener: ({ context, fire, event }) => {
+ fire.async(event.tabId, {
+ oldWindowId: event.oldWindowId,
+ oldPosition: event.oldPosition,
+ });
+ },
+ }),
+
+ onRemoved: this.tabEventRegistrar({
+ tabEvent: "tab-removed",
+ listener: ({ context, fire, event }) => {
+ fire.async(event.tabId, {
+ windowId: event.windowId,
+ isWindowClosing: event.isWindowClosing,
+ });
+ },
+ }),
+
+ onMoved({ context, fire }) {
+ let { tabManager } = this.extension;
+ let moveListener = async event => {
+ let nativeTab = event.target;
+ let nativeTabInfo = event.detail.tabInfo;
+ let tabmail = nativeTab.ownerDocument.getElementById("tabmail");
+ if (tabManager.canAccessTab(nativeTab)) {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(tabTracker.getId(nativeTabInfo), {
+ windowId: windowTracker.getId(nativeTab.ownerGlobal),
+ fromIndex: event.detail.idx,
+ toIndex: tabmail.tabInfo.indexOf(nativeTabInfo),
+ });
+ }
+ };
+
+ windowTracker.addListener("TabMove", moveListener);
+ return {
+ unregister() {
+ windowTracker.removeListener("TabMove", moveListener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+
+ onUpdated({ context, fire }, [filterProps]) {
+ let filter = { ...filterProps };
+ let scheduledEvents = [];
+
+ if (
+ filter &&
+ filter.urls &&
+ !this.extension.hasPermission("tabs") &&
+ !this.extension.hasPermission("activeTab")
+ ) {
+ console.error(
+ 'Url filtering in tabs.onUpdated requires "tabs" or "activeTab" permission.'
+ );
+ return false;
+ }
+
+ if (filter.urls) {
+ // TODO: Consider following M-C
+ // Use additional parameter { restrictSchemes: false }.
+ filter.urls = new MatchPatternSet(filter.urls);
+ }
+ let needsModified = true;
+ if (filter.properties) {
+ // Default is to listen for all events.
+ needsModified = filter.properties.some(prop => allAttrs.has(prop));
+ filter.properties = new Set(filter.properties);
+ } else {
+ filter.properties = allProperties;
+ }
+
+ function sanitize(tab, changeInfo) {
+ let result = {};
+ let nonempty = false;
+ for (let prop in changeInfo) {
+ // In practice, changeInfo contains at most one property from
+ // restricted. Therefore it is not necessary to cache the value
+ // of tab.hasTabPermission outside the loop.
+ // Unnecessarily accessing tab.hasTabPermission can cause bugs, see
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1694699#c21
+ if (tab.hasTabPermission || !restricted.has(prop)) {
+ nonempty = true;
+ result[prop] = changeInfo[prop];
+ }
+ }
+ return nonempty && result;
+ }
+
+ function getWindowID(windowId) {
+ if (windowId === WindowBase.WINDOW_ID_CURRENT) {
+ // TODO: Consider following M-C
+ // Use windowTracker.getTopWindow(context).
+ return windowTracker.getId(windowTracker.topWindow);
+ }
+ return windowId;
+ }
+
+ function matchFilters(tab, changed) {
+ if (!filterProps) {
+ return true;
+ }
+ if (filter.tabId != null && tab.id != filter.tabId) {
+ return false;
+ }
+ if (
+ filter.windowId != null &&
+ tab.windowId != getWindowID(filter.windowId)
+ ) {
+ return false;
+ }
+ if (filter.urls) {
+ // We check permission first because tab.uri is null if !hasTabPermission.
+ return tab.hasTabPermission && filter.urls.matches(tab.uri);
+ }
+ return true;
+ }
+
+ let fireForTab = async (tab, changed) => {
+ if (!matchFilters(tab, changed)) {
+ return;
+ }
+
+ let changeInfo = sanitize(tab, changed);
+ if (changeInfo) {
+ let tabInfo = tab.convert();
+ // TODO: Consider following M-C
+ // Use tabTracker.maybeWaitForTabOpen(nativeTab).then(() => {}).
+
+ // Using a FIFO to keep order of events, in case the last one
+ // gets through without being placed on the async callback stack.
+ scheduledEvents.push([tab.id, changeInfo, tabInfo]);
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ fire.async(...scheduledEvents.shift());
+ }
+ };
+
+ let listener = event => {
+ /* TODO: Consider following M-C
+ // Ignore any events prior to TabOpen and events that are triggered while
+ // tabs are swapped between windows.
+ if (event.originalTarget.initializingTab) {
+ return;
+ }
+ if (!extension.canAccessWindow(event.originalTarget.ownerGlobal)) {
+ return;
+ }
+ */
+
+ let changeInfo = {};
+ let { extension } = this;
+ let { tabManager } = extension;
+ let tab = tabManager.getWrapper(event.detail.tabInfo);
+ let changed = event.detail.changed;
+ if (
+ changed.includes("favIconUrl") &&
+ filter.properties.has("favIconUrl")
+ ) {
+ changeInfo.favIconUrl = tab.favIconUrl;
+ }
+ if (changed.includes("label") && filter.properties.has("title")) {
+ changeInfo.title = tab.title;
+ }
+
+ fireForTab(tab, changeInfo);
+ };
+
+ let statusListener = ({ browser, status, url }) => {
+ let { extension } = this;
+ let { tabManager } = extension;
+ let tabId = tabTracker.getBrowserTabId(browser);
+ if (tabId != -1) {
+ let changed = { status };
+ if (url) {
+ changed.url = url;
+ }
+ fireForTab(tabManager.get(tabId), changed);
+ }
+ };
+
+ if (needsModified) {
+ windowTracker.addListener("TabAttrModified", listener);
+ }
+
+ if (filter.properties.has("status")) {
+ windowTracker.addListener("status", statusListener);
+ }
+
+ return {
+ unregister() {
+ if (needsModified) {
+ windowTracker.removeListener("TabAttrModified", listener);
+ }
+ if (filter.properties.has("status")) {
+ windowTracker.removeListener("status", statusListener);
+ }
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ let { extension } = context;
+ let { tabManager } = extension;
+
+ /**
+ * Gets the tab for the given tab id, or the active tab if the id is null.
+ *
+ * @param {?Integer} tabId - The tab id to get
+ * @returns {Tab} The matching tab, or the active tab
+ */
+ function getTabOrActive(tabId) {
+ if (tabId) {
+ return tabTracker.getTab(tabId);
+ }
+ return tabTracker.activeTab;
+ }
+
+ /**
+ * Promise that the tab with the given tab id is ready.
+ *
+ * @param {Integer} tabId - The tab id to check
+ * @returns {Promise<NativeTabInfo>} Resolved when the loading is complete
+ */
+ async function promiseTabWhenReady(tabId) {
+ let tab;
+ if (tabId === null) {
+ tab = tabManager.getWrapper(tabTracker.activeTab);
+ } else {
+ tab = tabManager.get(tabId);
+ }
+
+ await tabListener.awaitTabReady(tab.nativeTab);
+
+ return tab;
+ }
+
+ return {
+ tabs: {
+ onActivated: new EventManager({
+ context,
+ module: "tabs",
+ event: "onActivated",
+ extensionApi: this,
+ }).api(),
+
+ onCreated: new EventManager({
+ context,
+ module: "tabs",
+ event: "onCreated",
+ extensionApi: this,
+ }).api(),
+
+ onAttached: new EventManager({
+ context,
+ module: "tabs",
+ event: "onAttached",
+ extensionApi: this,
+ }).api(),
+
+ onDetached: new EventManager({
+ context,
+ module: "tabs",
+ event: "onDetached",
+ extensionApi: this,
+ }).api(),
+
+ onRemoved: new EventManager({
+ context,
+ module: "tabs",
+ event: "onRemoved",
+ extensionApi: this,
+ }).api(),
+
+ onMoved: new EventManager({
+ context,
+ module: "tabs",
+ event: "onMoved",
+ extensionApi: this,
+ }).api(),
+
+ onUpdated: new EventManager({
+ context,
+ module: "tabs",
+ event: "onUpdated",
+ extensionApi: this,
+ }).api(),
+
+ async create(createProperties) {
+ let window = await getNormalWindowReady(
+ context,
+ createProperties.windowId
+ );
+ let tabmail = window.document.getElementById("tabmail");
+ let url;
+ if (createProperties.url) {
+ url = context.uri.resolve(createProperties.url);
+
+ if (!context.checkLoadURL(url, { dontReportErrors: true })) {
+ return Promise.reject({ message: `Illegal URL: ${url}` });
+ }
+ }
+
+ let userContextId =
+ Services.scriptSecurityManager.DEFAULT_USER_CONTEXT_ID;
+ if (createProperties.cookieStoreId) {
+ userContextId = getUserContextIdForCookieStoreId(
+ extension,
+ createProperties.cookieStoreId
+ );
+ }
+
+ let currentTab = tabmail.selectedTab;
+ let active = createProperties.active ?? true;
+ tabListener.initTabReady();
+
+ let nativeTabInfo = tabmail.openTab("contentTab", {
+ url: url || "about:blank",
+ linkHandler: "single-site",
+ background: !active,
+ initialBrowsingContextGroupId:
+ context.extension.policy.browsingContextGroupId,
+ principal: context.extension.principal,
+ duplicate: true,
+ userContextId,
+ });
+
+ if (createProperties.index) {
+ tabmail.moveTabTo(nativeTabInfo, createProperties.index);
+ tabmail.updateCurrentTab();
+ }
+
+ if (createProperties.url && createProperties.url !== "about:blank") {
+ // Mark tabs as initializing, so operations like `executeScript` wait until the
+ // requested URL is loaded.
+ tabListener.initializingTabs.add(nativeTabInfo);
+ }
+ return tabManager.convert(nativeTabInfo, currentTab);
+ },
+
+ async remove(tabs) {
+ if (!Array.isArray(tabs)) {
+ tabs = [tabs];
+ }
+
+ for (let tabId of tabs) {
+ let nativeTabInfo = tabTracker.getTab(tabId);
+ if (nativeTabInfo instanceof Ci.nsIDOMWindow) {
+ nativeTabInfo.close();
+ continue;
+ }
+ let tabmail = getTabTabmail(nativeTabInfo);
+ tabmail.closeTab(nativeTabInfo);
+ }
+ },
+
+ async update(tabId, updateProperties) {
+ let nativeTabInfo = getTabOrActive(tabId);
+ let tab = tabManager.getWrapper(nativeTabInfo);
+ let tabmail = getTabTabmail(nativeTabInfo);
+
+ if (updateProperties.url) {
+ let url = context.uri.resolve(updateProperties.url);
+ if (!context.checkLoadURL(url, { dontReportErrors: true })) {
+ return Promise.reject({ message: `Illegal URL: ${url}` });
+ }
+
+ let uri;
+ try {
+ uri = Services.io.newURI(url);
+ } catch (e) {
+ throw new ExtensionError(`Url "${url}" seems to be malformed.`);
+ }
+
+ // http(s): urls, moz-extension: urls and self-registered protocol
+ // handlers are actually loaded into the tab (and change its url).
+ // All other urls are forwarded to the external protocol handler and
+ // do not change the current tab.
+ let isContentUrl =
+ /((^blob:)|(^https:)|(^http:)|(^moz-extension:))/i.test(url);
+ let isWebExtProtocolUrl =
+ /((^ext\+[a-z]+:)|(^web\+[a-z]+:))/i.test(url) &&
+ hasWebHandlerApp(uri.scheme);
+
+ if (isContentUrl || isWebExtProtocolUrl) {
+ if (tab.type != "content" && tab.type != "mail") {
+ throw new ExtensionError(
+ isContentUrl
+ ? "Loading a content url is only supported for content tabs and mail tabs."
+ : "Loading a registered WebExtension protocol handler url is only supported for content tabs and mail tabs."
+ );
+ }
+
+ let options = {
+ flags: updateProperties.loadReplace
+ ? Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY
+ : Ci.nsIWebNavigation.LOAD_FLAGS_NONE,
+ triggeringPrincipal: context.principal,
+ };
+
+ if (tab.type == "mail") {
+ // The content browser in about:3pane.
+ nativeTabInfo.chromeBrowser.contentWindow.messagePane.displayWebPage(
+ url,
+ options
+ );
+ } else {
+ let browser = getTabBrowser(nativeTabInfo);
+ if (!browser) {
+ throw new ExtensionError("Cannot set a URL for this tab.");
+ }
+ MailE10SUtils.loadURI(browser, url, options);
+ }
+ } else {
+ // Send unknown URLs schema to the external protocol handler.
+ // This does not change the current tab.
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(uri);
+ }
+ }
+
+ // A tab can only be set to be active. To set it inactive, another tab
+ // has to be set as active.
+ if (tabmail && updateProperties.active) {
+ tabmail.selectedTab = nativeTabInfo;
+ }
+
+ return tabManager.convert(nativeTabInfo);
+ },
+
+ async reload(tabId, reloadProperties) {
+ let nativeTabInfo = getTabOrActive(tabId);
+ let tab = tabManager.getWrapper(nativeTabInfo);
+
+ let isContentMailTab =
+ tab.type == "mail" &&
+ !nativeTabInfo.chromeBrowser.contentWindow.webBrowser.hidden;
+ if (tab.type != "content" && !isContentMailTab) {
+ throw new ExtensionError(
+ "Reloading is only supported for tabs displaying a content page."
+ );
+ }
+
+ let browser = getTabBrowser(nativeTabInfo);
+
+ let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
+ if (reloadProperties && reloadProperties.bypassCache) {
+ flags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE;
+ }
+ browser.reloadWithFlags(flags);
+ },
+
+ async get(tabId) {
+ return tabManager.get(tabId).convert();
+ },
+
+ getCurrent() {
+ let tabData;
+ if (context.tabId) {
+ tabData = tabManager.get(context.tabId).convert();
+ }
+ return Promise.resolve(tabData);
+ },
+
+ async query(queryInfo) {
+ if (!extension.hasPermission("tabs")) {
+ if (queryInfo.url !== null || queryInfo.title !== null) {
+ return Promise.reject({
+ message:
+ 'The "tabs" permission is required to use the query API with the "url" or "title" parameters',
+ });
+ }
+ }
+
+ // Make ext-tabs-base happy since it does a strict check.
+ queryInfo.screen = null;
+
+ return Array.from(tabManager.query(queryInfo, context), tab =>
+ tab.convert()
+ );
+ },
+
+ async executeScript(tabId, details) {
+ let tab = await promiseTabWhenReady(tabId);
+ return tab.executeScript(context, details);
+ },
+
+ async insertCSS(tabId, details) {
+ let tab = await promiseTabWhenReady(tabId);
+ return tab.insertCSS(context, details);
+ },
+
+ async removeCSS(tabId, details) {
+ let tab = await promiseTabWhenReady(tabId);
+ return tab.removeCSS(context, details);
+ },
+
+ async move(tabIds, moveProperties) {
+ let tabsMoved = [];
+ if (!Array.isArray(tabIds)) {
+ tabIds = [tabIds];
+ }
+
+ let destinationWindow = null;
+ if (moveProperties.windowId !== null) {
+ destinationWindow = await getNormalWindowReady(
+ context,
+ moveProperties.windowId
+ );
+ }
+
+ /*
+ Indexes are maintained on a per window basis so that a call to
+ move([tabA, tabB], {index: 0})
+ -> tabA to 0, tabB to 1 if tabA and tabB are in the same window
+ move([tabA, tabB], {index: 0})
+ -> tabA to 0, tabB to 0 if tabA and tabB are in different windows
+ */
+ let indexMap = new Map();
+ let lastInsertion = new Map();
+
+ let tabs = tabIds.map(tabId => ({
+ nativeTabInfo: tabTracker.getTab(tabId),
+ tabId,
+ }));
+ for (let { nativeTabInfo, tabId } of tabs) {
+ if (nativeTabInfo instanceof Ci.nsIDOMWindow) {
+ return Promise.reject({
+ message: `Tab with ID ${tabId} does not belong to a normal window`,
+ });
+ }
+
+ // If the window is not specified, use the window from the tab.
+ let browser = getTabBrowser(nativeTabInfo);
+
+ let srcwindow = browser.ownerGlobal;
+ let tgtwindow = destinationWindow || browser.ownerGlobal;
+ let tgttabmail = tgtwindow.document.getElementById("tabmail");
+ let srctabmail = srcwindow.document.getElementById("tabmail");
+
+ // If we are not moving the tab to a different window, and the window
+ // only has one tab, do nothing.
+ if (srcwindow == tgtwindow && srctabmail.tabInfo.length === 1) {
+ continue;
+ }
+
+ let insertionPoint =
+ indexMap.get(tgtwindow) || moveProperties.index;
+ // If the index is -1 it should go to the end of the tabs.
+ if (insertionPoint == -1) {
+ insertionPoint = tgttabmail.tabInfo.length;
+ }
+
+ let tabPosition = srctabmail.tabInfo.indexOf(nativeTabInfo);
+
+ // If this is not the first tab to be inserted into this window and
+ // the insertion point is the same as the last insertion and
+ // the tab is further to the right than the current insertion point
+ // then you need to bump up the insertion point. See bug 1323311.
+ if (
+ lastInsertion.has(tgtwindow) &&
+ lastInsertion.get(tgtwindow) === insertionPoint &&
+ tabPosition > insertionPoint
+ ) {
+ insertionPoint++;
+ indexMap.set(tgtwindow, insertionPoint);
+ }
+
+ if (srcwindow == tgtwindow) {
+ // If the window we are moving is the same, just move the tab.
+ tgttabmail.moveTabTo(nativeTabInfo, insertionPoint);
+ } else {
+ // If the window we are moving the tab in is different, then move the tab
+ // to the new window.
+ srctabmail.replaceTabWithWindow(
+ nativeTabInfo,
+ tgtwindow,
+ insertionPoint
+ );
+ nativeTabInfo =
+ tgttabmail.tabInfo[insertionPoint] ||
+ tgttabmail.tabInfo[tgttabmail.tabInfo.length - 1];
+ }
+ lastInsertion.set(tgtwindow, tabPosition);
+ tabsMoved.push(nativeTabInfo);
+ }
+
+ return tabsMoved.map(nativeTabInfo =>
+ tabManager.convert(nativeTabInfo)
+ );
+ },
+
+ duplicate(tabId) {
+ let nativeTabInfo = tabTracker.getTab(tabId);
+ if (nativeTabInfo instanceof Ci.nsIDOMWindow) {
+ throw new ExtensionError(
+ "tabs.duplicate is not applicable to this tab."
+ );
+ }
+ let browser = getTabBrowser(nativeTabInfo);
+ let tabmail = browser.ownerDocument.getElementById("tabmail");
+
+ // This is our best approximation of duplicating tabs. It might produce unreliable results
+ let state = tabmail.persistTab(nativeTabInfo);
+ let mode = tabmail.tabModes[state.mode];
+ state.state.duplicate = true;
+
+ if (mode.tabs.length && mode.tabs.length == mode.maxTabs) {
+ throw new ExtensionError(
+ `Maximum number of ${state.mode} tabs reached.`
+ );
+ } else {
+ tabmail.restoreTab(state);
+ return tabManager.convert(mode.tabs[mode.tabs.length - 1]);
+ }
+ },
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-theme.js b/comm/mail/components/extensions/parent/ext-theme.js
new file mode 100644
index 0000000000..1de3501e84
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-theme.js
@@ -0,0 +1,543 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global windowTracker, EventManager, EventEmitter */
+
+/* eslint-disable complexity */
+
+ChromeUtils.defineESModuleGetters(this, {
+ LightweightThemeManager:
+ "resource://gre/modules/LightweightThemeManager.sys.mjs",
+});
+
+const onUpdatedEmitter = new EventEmitter();
+
+// Represents an empty theme for convenience of use
+const emptyTheme = {
+ details: { colors: null, images: null, properties: null },
+};
+
+let defaultTheme = emptyTheme;
+// Map[windowId -> Theme instance]
+let windowOverrides = new Map();
+
+/**
+ * Class representing either a global theme affecting all windows or an override on a specific window.
+ * Any extension updating the theme with a new global theme will replace the singleton defaultTheme.
+ */
+class Theme {
+ /**
+ * Creates a theme instance.
+ *
+ * @param {string} extension - Extension that created the theme.
+ * @param {Integer} windowId - The windowId where the theme is applied.
+ */
+ constructor({
+ extension,
+ details,
+ darkDetails,
+ windowId,
+ experiment,
+ startupData,
+ }) {
+ this.extension = extension;
+ this.details = details;
+ this.darkDetails = darkDetails;
+ this.windowId = windowId;
+
+ if (startupData && startupData.lwtData) {
+ Object.assign(this, startupData);
+ } else {
+ // TODO: Update this part after bug 1550090.
+ this.lwtStyles = {};
+ this.lwtDarkStyles = null;
+ if (darkDetails) {
+ this.lwtDarkStyles = {};
+ }
+
+ if (experiment) {
+ if (extension.canUseThemeExperiment()) {
+ this.lwtStyles.experimental = {
+ colors: {},
+ images: {},
+ properties: {},
+ };
+ if (this.lwtDarkStyles) {
+ this.lwtDarkStyles.experimental = {
+ colors: {},
+ images: {},
+ properties: {},
+ };
+ }
+
+ if (experiment.stylesheet) {
+ experiment.stylesheet = this.getFileUrl(experiment.stylesheet);
+ }
+ this.experiment = experiment;
+ } else {
+ const { logger } = this.extension;
+ logger.warn("This extension is not allowed to run theme experiments");
+ return;
+ }
+ }
+ }
+ this.load();
+ }
+
+ // The manifest has moz-extension:// urls. Switch to file:// urls to get around
+ // the skin limitation for moz-extension:// urls.
+ getFileUrl(url) {
+ if (url.startsWith("moz-extension://")) {
+ url = url.split("/").slice(3).join("/");
+ }
+ return this.extension.rootURI.resolve(url);
+ }
+
+ /**
+ * Loads a theme by reading the properties from the extension's manifest.
+ * This method will override any currently applied theme.
+ */
+ load() {
+ if (!this.lwtData) {
+ this.loadDetails(this.details, this.lwtStyles);
+ if (this.darkDetails) {
+ this.loadDetails(this.darkDetails, this.lwtDarkStyles);
+ }
+
+ this.lwtData = {
+ theme: this.lwtStyles,
+ darkTheme: this.lwtDarkStyles,
+ };
+
+ if (this.experiment) {
+ this.lwtData.experiment = this.experiment;
+ }
+
+ this.extension.startupData = {
+ lwtData: this.lwtData,
+ lwtStyles: this.lwtStyles,
+ lwtDarkStyles: this.lwtDarkStyles,
+ experiment: this.experiment,
+ };
+ this.extension.saveStartupData();
+ }
+
+ if (this.windowId) {
+ this.lwtData.window = windowTracker.getWindow(
+ this.windowId
+ ).docShell.outerWindowID;
+ windowOverrides.set(this.windowId, this);
+ } else {
+ windowOverrides.clear();
+ defaultTheme = this;
+ LightweightThemeManager.fallbackThemeData = this.lwtData;
+ }
+ onUpdatedEmitter.emit("theme-updated", this.details, this.windowId);
+
+ Services.obs.notifyObservers(
+ this.lwtData,
+ "lightweight-theme-styling-update"
+ );
+ }
+
+ /**
+ * @param {object} details - Details
+ * @param {object} styles - Styles object in which to store the colors.
+ */
+ loadDetails(details, styles) {
+ if (details.colors) {
+ this.loadColors(details.colors, styles);
+ }
+
+ if (details.images) {
+ this.loadImages(details.images, styles);
+ }
+
+ if (details.properties) {
+ this.loadProperties(details.properties, styles);
+ }
+
+ this.loadMetadata(this.extension, styles);
+ }
+
+ /**
+ * Helper method for loading colors found in the extension's manifest.
+ *
+ * @param {object} colors - Dictionary mapping color properties to values.
+ * @param {object} styles - Styles object in which to store the colors.
+ */
+ loadColors(colors, styles) {
+ for (let color of Object.keys(colors)) {
+ let val = colors[color];
+
+ if (!val) {
+ continue;
+ }
+
+ let cssColor = val;
+ if (Array.isArray(val)) {
+ cssColor =
+ "rgb" + (val.length > 3 ? "a" : "") + "(" + val.join(",") + ")";
+ }
+
+ switch (color) {
+ case "frame":
+ styles.accentcolor = cssColor;
+ break;
+ case "frame_inactive":
+ styles.accentcolorInactive = cssColor;
+ break;
+ case "tab_background_text":
+ styles.textcolor = cssColor;
+ break;
+ case "toolbar":
+ styles.toolbarColor = cssColor;
+ break;
+ case "toolbar_text":
+ case "bookmark_text":
+ styles.toolbar_text = cssColor;
+ break;
+ case "icons":
+ styles.icon_color = cssColor;
+ break;
+ case "icons_attention":
+ styles.icon_attention_color = cssColor;
+ break;
+ case "tab_background_separator":
+ case "tab_loading":
+ case "tab_text":
+ case "tab_line":
+ case "tab_selected":
+ case "toolbar_field":
+ case "toolbar_field_text":
+ case "toolbar_field_border":
+ case "toolbar_field_focus":
+ case "toolbar_field_text_focus":
+ case "toolbar_field_border_focus":
+ case "toolbar_top_separator":
+ case "toolbar_bottom_separator":
+ case "toolbar_vertical_separator":
+ case "button_background_hover":
+ case "button_background_active":
+ case "popup":
+ case "popup_text":
+ case "popup_border":
+ case "popup_highlight":
+ case "popup_highlight_text":
+ case "ntp_background":
+ case "ntp_text":
+ case "sidebar":
+ case "sidebar_border":
+ case "sidebar_text":
+ case "sidebar_highlight":
+ case "sidebar_highlight_text":
+ case "sidebar_highlight_border":
+ case "toolbar_field_highlight":
+ case "toolbar_field_highlight_text":
+ styles[color] = cssColor;
+ break;
+ default:
+ if (
+ this.experiment &&
+ this.experiment.colors &&
+ color in this.experiment.colors
+ ) {
+ styles.experimental.colors[color] = cssColor;
+ } else {
+ const { logger } = this.extension;
+ logger.warn(`Unrecognized theme property found: colors.${color}`);
+ }
+ break;
+ }
+ }
+ }
+
+ /**
+ * Helper method for loading images found in the extension's manifest.
+ *
+ * @param {object} images - Dictionary mapping image properties to values.
+ * @param {object} styles - Styles object in which to store the colors.
+ */
+ loadImages(images, styles) {
+ const { logger } = this.extension;
+
+ for (let image of Object.keys(images)) {
+ let val = images[image];
+
+ if (!val) {
+ continue;
+ }
+
+ switch (image) {
+ case "additional_backgrounds": {
+ let backgroundImages = val.map(img => this.getFileUrl(img));
+ styles.additionalBackgrounds = backgroundImages;
+ break;
+ }
+ case "theme_frame": {
+ let resolvedURL = this.getFileUrl(val);
+ styles.headerURL = resolvedURL;
+ break;
+ }
+ default: {
+ if (
+ this.experiment &&
+ this.experiment.images &&
+ image in this.experiment.images
+ ) {
+ styles.experimental.images[image] = this.getFileUrl(val);
+ } else {
+ logger.warn(`Unrecognized theme property found: images.${image}`);
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ /**
+ * Helper method for preparing properties found in the extension's manifest.
+ * Properties are commonly used to specify more advanced behavior of colors,
+ * images or icons.
+ *
+ * @param {object} - properties Dictionary mapping properties to values.
+ * @param {object} - styles Styles object in which to store the colors.
+ */
+ loadProperties(properties, styles) {
+ let additionalBackgroundsCount =
+ (styles.additionalBackgrounds && styles.additionalBackgrounds.length) ||
+ 0;
+ const assertValidAdditionalBackgrounds = (property, valueCount) => {
+ const { logger } = this.extension;
+ if (!additionalBackgroundsCount) {
+ logger.warn(
+ `The '${property}' property takes effect only when one ` +
+ `or more additional background images are specified using the 'additional_backgrounds' property.`
+ );
+ return false;
+ }
+ if (additionalBackgroundsCount !== valueCount) {
+ logger.warn(
+ `The amount of values specified for '${property}' ` +
+ `(${valueCount}) is not equal to the amount of additional background ` +
+ `images (${additionalBackgroundsCount}), which may lead to unexpected results.`
+ );
+ }
+ return true;
+ };
+
+ for (let property of Object.getOwnPropertyNames(properties)) {
+ let val = properties[property];
+
+ if (!val) {
+ continue;
+ }
+
+ switch (property) {
+ case "additional_backgrounds_alignment": {
+ if (!assertValidAdditionalBackgrounds(property, val.length)) {
+ break;
+ }
+
+ styles.backgroundsAlignment = val.join(",");
+ break;
+ }
+ case "additional_backgrounds_tiling": {
+ if (!assertValidAdditionalBackgrounds(property, val.length)) {
+ break;
+ }
+
+ let tiling = [];
+ for (let i = 0, l = styles.additionalBackgrounds.length; i < l; ++i) {
+ tiling.push(val[i] || "no-repeat");
+ }
+ styles.backgroundsTiling = tiling.join(",");
+ break;
+ }
+ case "color_scheme":
+ case "content_color_scheme": {
+ styles[property] = val;
+ break;
+ }
+ default: {
+ if (
+ this.experiment &&
+ this.experiment.properties &&
+ property in this.experiment.properties
+ ) {
+ styles.experimental.properties[property] = val;
+ } else {
+ const { logger } = this.extension;
+ logger.warn(
+ `Unrecognized theme property found: properties.${property}`
+ );
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ /**
+ * Helper method for loading extension metadata required by downstream
+ * consumers.
+ *
+ * @param {object} extension - Extension object.
+ * @param {object} styles - Styles object in which to store the colors.
+ */
+ loadMetadata(extension, styles) {
+ styles.id = extension.id;
+ styles.version = extension.version;
+ }
+
+ static unload(windowId) {
+ let lwtData = {
+ theme: null,
+ };
+
+ if (windowId) {
+ lwtData.window = windowTracker.getWindow(windowId).docShell.outerWindowID;
+ windowOverrides.delete(windowId);
+ } else {
+ windowOverrides.clear();
+ defaultTheme = emptyTheme;
+ LightweightThemeManager.fallbackThemeData = null;
+ }
+ onUpdatedEmitter.emit("theme-updated", {}, windowId);
+
+ Services.obs.notifyObservers(lwtData, "lightweight-theme-styling-update");
+ }
+}
+
+this.theme = class extends ExtensionAPIPersistent {
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called).
+
+ onUpdated({ fire, context }) {
+ let callback = async (event, theme, windowId) => {
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ if (windowId) {
+ // Force access validation for incognito mode by getting the window.
+ if (windowTracker.getWindow(windowId, context, false)) {
+ fire.async({ theme, windowId });
+ }
+ } else {
+ fire.async({ theme });
+ }
+ };
+
+ onUpdatedEmitter.on("theme-updated", callback);
+ return {
+ unregister() {
+ onUpdatedEmitter.off("theme-updated", callback);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ onManifestEntry(entryName) {
+ let { extension } = this;
+ let { manifest } = extension;
+
+ defaultTheme = new Theme({
+ extension,
+ details: manifest.theme,
+ darkDetails: manifest.dark_theme,
+ experiment: manifest.theme_experiment,
+ startupData: extension.startupData,
+ });
+ }
+
+ onShutdown(isAppShutdown) {
+ if (isAppShutdown) {
+ return;
+ }
+
+ let { extension } = this;
+ for (let [windowId, theme] of windowOverrides) {
+ if (theme.extension === extension) {
+ Theme.unload(windowId);
+ }
+ }
+
+ if (defaultTheme.extension === extension) {
+ Theme.unload();
+ }
+ }
+
+ getAPI(context) {
+ let { extension } = context;
+
+ return {
+ theme: {
+ getCurrent: windowId => {
+ // Take last focused window when no ID is supplied.
+ if (!windowId) {
+ windowId = windowTracker.getId(windowTracker.topWindow);
+ }
+ // Force access validation for incognito mode by getting the window.
+ if (!windowTracker.getWindow(windowId, context)) {
+ return Promise.reject(`Invalid window ID: ${windowId}`);
+ }
+
+ if (windowOverrides.has(windowId)) {
+ return Promise.resolve(windowOverrides.get(windowId).details);
+ }
+ return Promise.resolve(defaultTheme.details);
+ },
+ update: (windowId, details) => {
+ if (windowId) {
+ const browserWindow = windowTracker.getWindow(windowId, context);
+ if (!browserWindow) {
+ return Promise.reject(`Invalid window ID: ${windowId}`);
+ }
+ }
+
+ new Theme({
+ extension,
+ details,
+ windowId,
+ experiment: this.extension.manifest.theme_experiment,
+ });
+
+ return Promise.resolve();
+ },
+ reset: windowId => {
+ if (windowId) {
+ const browserWindow = windowTracker.getWindow(windowId, context);
+ if (!browserWindow) {
+ return Promise.reject(`Invalid window ID: ${windowId}`);
+ }
+
+ let theme = windowOverrides.get(windowId) || defaultTheme;
+ if (theme.extension !== extension) {
+ return Promise.resolve();
+ }
+ } else if (defaultTheme.extension !== extension) {
+ return Promise.resolve();
+ }
+
+ Theme.unload(windowId);
+ return Promise.resolve();
+ },
+ onUpdated: new EventManager({
+ context,
+ module: "theme",
+ event: "onUpdated",
+ extensionApi: this,
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/comm/mail/components/extensions/parent/ext-windows.js b/comm/mail/components/extensions/parent/ext-windows.js
new file mode 100644
index 0000000000..6a3078d7d3
--- /dev/null
+++ b/comm/mail/components/extensions/parent/ext-windows.js
@@ -0,0 +1,555 @@
+/* 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/. */
+
+// The ext-* files are imported into the same scopes.
+/* import-globals-from ext-mail.js */
+
+function sanitizePositionParams(params, window = null, positionOffset = 0) {
+ if (params.left === null && params.top === null) {
+ return;
+ }
+
+ if (params.left === null) {
+ const baseLeft = window ? window.screenX : 0;
+ params.left = baseLeft + positionOffset;
+ }
+ if (params.top === null) {
+ const baseTop = window ? window.screenY : 0;
+ params.top = baseTop + positionOffset;
+ }
+
+ // boundary check: don't put window out of visible area
+ const baseWidth = window ? window.outerWidth : 0;
+ const baseHeight = window ? window.outerHeight : 0;
+ // Secure minimum size of an window should be same to the one
+ // defined at nsGlobalWindowOuter::CheckSecurityWidthAndHeight.
+ const minWidth = 100;
+ const minHeight = 100;
+ const width = Math.max(
+ minWidth,
+ params.width !== null ? params.width : baseWidth
+ );
+ const height = Math.max(
+ minHeight,
+ params.height !== null ? params.height : baseHeight
+ );
+ const screenManager = Cc["@mozilla.org/gfx/screenmanager;1"].getService(
+ Ci.nsIScreenManager
+ );
+ const screen = screenManager.screenForRect(
+ params.left,
+ params.top,
+ width,
+ height
+ );
+ const availDeviceLeft = {};
+ const availDeviceTop = {};
+ const availDeviceWidth = {};
+ const availDeviceHeight = {};
+ screen.GetAvailRect(
+ availDeviceLeft,
+ availDeviceTop,
+ availDeviceWidth,
+ availDeviceHeight
+ );
+ const factor = screen.defaultCSSScaleFactor;
+ const availLeft = Math.floor(availDeviceLeft.value / factor);
+ const availTop = Math.floor(availDeviceTop.value / factor);
+ const availWidth = Math.floor(availDeviceWidth.value / factor);
+ const availHeight = Math.floor(availDeviceHeight.value / factor);
+ params.left = Math.min(
+ availLeft + availWidth - width,
+ Math.max(availLeft, params.left)
+ );
+ params.top = Math.min(
+ availTop + availHeight - height,
+ Math.max(availTop, params.top)
+ );
+}
+
+/**
+ * Update the geometry of the mail window.
+ *
+ * @param {object} options
+ * An object containing new values for the window's geometry.
+ * @param {integer} [options.left]
+ * The new pixel distance of the left side of the mail window from
+ * the left of the screen.
+ * @param {integer} [options.top]
+ * The new pixel distance of the top side of the mail window from
+ * the top of the screen.
+ * @param {integer} [options.width]
+ * The new pixel width of the window.
+ * @param {integer} [options.height]
+ * The new pixel height of the window.
+ */
+function updateGeometry(window, options) {
+ if (options.left !== null || options.top !== null) {
+ let left = options.left === null ? window.screenX : options.left;
+ let top = options.top === null ? window.screenY : options.top;
+ window.moveTo(left, top);
+ }
+
+ if (options.width !== null || options.height !== null) {
+ let width = options.width === null ? window.outerWidth : options.width;
+ let height = options.height === null ? window.outerHeight : options.height;
+ window.resizeTo(width, height);
+ }
+}
+
+this.windows = class extends ExtensionAPIPersistent {
+ onShutdown(isAppShutdown) {
+ if (isAppShutdown) {
+ return;
+ }
+ for (let window of Services.wm.getEnumerator("mail:extensionPopup")) {
+ let uri = window.browser.browsingContext.currentURI;
+ if (uri.scheme == "moz-extension" && uri.host == this.extension.uuid) {
+ window.close();
+ }
+ }
+ }
+
+ windowEventRegistrar({ windowEvent, listener }) {
+ let { extension } = this;
+ return ({ context, fire }) => {
+ let listener2 = async (window, ...args) => {
+ if (!extension.canAccessWindow(window)) {
+ return;
+ }
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ listener({ context, fire, window }, ...args);
+ };
+ windowTracker.addListener(windowEvent, listener2);
+ return {
+ unregister() {
+ windowTracker.removeListener(windowEvent, listener2);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ };
+ }
+
+ PERSISTENT_EVENTS = {
+ // For primed persistent events (deactivated background), the context is only
+ // available after fire.wakeup() has fulfilled (ensuring the convert() function
+ // has been called) (handled by windowEventRegistrar).
+
+ onCreated: this.windowEventRegistrar({
+ windowEvent: "domwindowopened",
+ listener: async ({ context, fire, window }) => {
+ // Return the window only after it has been fully initialized.
+ if (window.webExtensionWindowCreatePending) {
+ await new Promise(resolve => {
+ window.addEventListener("webExtensionWindowCreateDone", resolve, {
+ once: true,
+ });
+ });
+ }
+ fire.async(this.extension.windowManager.convert(window));
+ },
+ }),
+
+ onRemoved: this.windowEventRegistrar({
+ windowEvent: "domwindowclosed",
+ listener: ({ context, fire, window }) => {
+ fire.async(windowTracker.getId(window));
+ },
+ }),
+
+ onFocusChanged({ context, fire }) {
+ let { extension } = this;
+ // Keep track of the last windowId used to fire an onFocusChanged event
+ let lastOnFocusChangedWindowId;
+ let scheduledEvents = [];
+
+ let listener = async event => {
+ // Wait a tick to avoid firing a superfluous WINDOW_ID_NONE
+ // event when switching focus between two Thunderbird windows.
+ // Note: This is not working for Linux, where we still get the -1
+ await Promise.resolve();
+
+ let windowId = WindowBase.WINDOW_ID_NONE;
+ let window = Services.focus.activeWindow;
+ if (window) {
+ if (!extension.canAccessWindow(window)) {
+ return;
+ }
+ windowId = windowTracker.getId(window);
+ }
+
+ // Using a FIFO to keep order of events, in case the last one
+ // gets through without being placed on the async callback stack.
+ scheduledEvents.push(windowId);
+ if (fire.wakeup) {
+ await fire.wakeup();
+ }
+ let scheduledWindowId = scheduledEvents.shift();
+
+ if (scheduledWindowId !== lastOnFocusChangedWindowId) {
+ lastOnFocusChangedWindowId = scheduledWindowId;
+ fire.async(scheduledWindowId);
+ }
+ };
+ windowTracker.addListener("focus", listener);
+ windowTracker.addListener("blur", listener);
+ return {
+ unregister() {
+ windowTracker.removeListener("focus", listener);
+ windowTracker.removeListener("blur", listener);
+ },
+ convert(newFire, extContext) {
+ fire = newFire;
+ context = extContext;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ const { extension } = context;
+ const { windowManager } = extension;
+
+ return {
+ windows: {
+ onCreated: new EventManager({
+ context,
+ module: "windows",
+ event: "onCreated",
+ extensionApi: this,
+ }).api(),
+
+ onRemoved: new EventManager({
+ context,
+ module: "windows",
+ event: "onRemoved",
+ extensionApi: this,
+ }).api(),
+
+ onFocusChanged: new EventManager({
+ context,
+ module: "windows",
+ event: "onFocusChanged",
+ extensionApi: this,
+ }).api(),
+
+ get(windowId, getInfo) {
+ let window = windowTracker.getWindow(windowId, context);
+ if (!window) {
+ return Promise.reject({
+ message: `Invalid window ID: ${windowId}`,
+ });
+ }
+ return Promise.resolve(windowManager.convert(window, getInfo));
+ },
+
+ async getCurrent(getInfo) {
+ let window = context.currentWindow || windowTracker.topWindow;
+ if (window.document.readyState != "complete") {
+ await new Promise(resolve =>
+ window.addEventListener("load", resolve, { once: true })
+ );
+ }
+ return windowManager.convert(window, getInfo);
+ },
+
+ async getLastFocused(getInfo) {
+ let window = windowTracker.topWindow;
+ if (window.document.readyState != "complete") {
+ await new Promise(resolve =>
+ window.addEventListener("load", resolve, { once: true })
+ );
+ }
+ return windowManager.convert(window, getInfo);
+ },
+
+ getAll(getInfo) {
+ let doNotCheckTypes = !getInfo || !getInfo.windowTypes;
+
+ let windows = Array.from(windowManager.getAll(), win =>
+ win.convert(getInfo)
+ ).filter(
+ win => doNotCheckTypes || getInfo.windowTypes.includes(win.type)
+ );
+ return Promise.resolve(windows);
+ },
+
+ async create(createData) {
+ if (createData.incognito) {
+ throw new ExtensionError("`incognito` is not supported");
+ }
+
+ let needResize =
+ createData.left !== null ||
+ createData.top !== null ||
+ createData.width !== null ||
+ createData.height !== null;
+ if (needResize) {
+ if (createData.state !== null && createData.state != "normal") {
+ throw new ExtensionError(
+ `"state": "${createData.state}" may not be combined with "left", "top", "width", or "height"`
+ );
+ }
+ createData.state = "normal";
+ }
+
+ // 10px offset is same to Chromium
+ sanitizePositionParams(createData, windowTracker.topNormalWindow, 10);
+
+ let userContextId =
+ Services.scriptSecurityManager.DEFAULT_USER_CONTEXT_ID;
+ if (createData.cookieStoreId) {
+ userContextId = getUserContextIdForCookieStoreId(
+ extension,
+ createData.cookieStoreId
+ );
+ }
+ let createWindowArgs = createData => {
+ let allowScriptsToClose = !!createData.allowScriptsToClose;
+ let url = createData.url || "about:blank";
+ let urls = Array.isArray(url) ? url : [url];
+
+ let args = Cc["@mozilla.org/array;1"].createInstance(
+ Ci.nsIMutableArray
+ );
+ let actionData = {
+ action: "open",
+ allowScriptsToClose,
+ tabs: urls.map(url => ({
+ tabType: "contentTab",
+ tabParams: { url, userContextId },
+ })),
+ };
+ actionData.wrappedJSObject = actionData;
+ args.appendElement(null);
+ args.appendElement(actionData);
+ return args;
+ };
+
+ let window;
+ let wantNormalWindow =
+ createData.type === null || createData.type == "normal";
+ let features = ["chrome"];
+ if (wantNormalWindow) {
+ features.push("dialog=no", "all", "status", "toolbar");
+ } else {
+ // All other types create "popup"-type windows by default.
+ // Use dialog=no to get minimize and maximize buttons (as chrome
+ // does) and to allow the API to actually maximize the popup in
+ // Linux.
+ features.push(
+ "dialog=no",
+ "resizable",
+ "minimizable",
+ "titlebar",
+ "close"
+ );
+ if (createData.left === null && createData.top === null) {
+ features.push("centerscreen");
+ }
+ }
+
+ let windowURL = wantNormalWindow
+ ? "chrome://messenger/content/messenger.xhtml"
+ : "chrome://messenger/content/extensionPopup.xhtml";
+ if (createData.tabId) {
+ if (createData.url) {
+ return Promise.reject({
+ message: "`tabId` may not be used in conjunction with `url`",
+ });
+ }
+
+ if (createData.allowScriptsToClose) {
+ return Promise.reject({
+ message:
+ "`tabId` may not be used in conjunction with `allowScriptsToClose`",
+ });
+ }
+
+ if (createData.cookieStoreId) {
+ return Promise.reject({
+ message:
+ "`tabId` may not be used in conjunction with `cookieStoreId`",
+ });
+ }
+
+ let nativeTabInfo = tabTracker.getTab(createData.tabId);
+ let tabmail =
+ getTabBrowser(nativeTabInfo).ownerDocument.getElementById(
+ "tabmail"
+ );
+ let targetType = wantNormalWindow ? null : "popup";
+ window = tabmail.replaceTabWithWindow(nativeTabInfo, targetType)[0];
+ } else {
+ window = Services.ww.openWindow(
+ null,
+ windowURL,
+ "_blank",
+ features.join(","),
+ wantNormalWindow ? null : createWindowArgs(createData)
+ );
+ }
+
+ window.webExtensionWindowCreatePending = true;
+
+ updateGeometry(window, createData);
+
+ // TODO: focused, type
+
+ // Wait till the newly created window is focused. On Linux the initial
+ // "normal" state has been set once the window has been fully focused.
+ // Setting a different state before the window is fully focused may cause
+ // the initial state to be erroneously applied after the custom state has
+ // been set.
+ let focusPromise = new Promise(resolve => {
+ if (Services.focus.activeWindow == window) {
+ resolve();
+ } else {
+ window.addEventListener("focus", resolve, { once: true });
+ }
+ });
+
+ let loadPromise = new Promise(resolve => {
+ window.addEventListener("load", resolve, { once: true });
+ });
+
+ let titlePromise = new Promise(resolve => {
+ window.addEventListener("pagetitlechanged", resolve, {
+ once: true,
+ });
+ });
+
+ await Promise.all([focusPromise, loadPromise, titlePromise]);
+
+ let win = windowManager.getWrapper(window);
+
+ if (
+ [
+ "minimized",
+ "fullscreen",
+ "docked",
+ "normal",
+ "maximized",
+ ].includes(createData.state)
+ ) {
+ await win.setState(createData.state);
+ }
+
+ if (createData.titlePreface !== null) {
+ win.setTitlePreface(createData.titlePreface);
+ }
+
+ // Update the title independently of a createData.titlePreface, to get
+ // the title of the loaded document into the window title.
+ if (win instanceof TabmailWindow) {
+ win.window.document.getElementById("tabmail").setDocumentTitle();
+ } else if (win.window.gBrowser?.updateTitlebar) {
+ await win.window.gBrowser.updateTitlebar();
+ }
+
+ delete window.webExtensionWindowCreatePending;
+ window.dispatchEvent(
+ new window.CustomEvent("webExtensionWindowCreateDone")
+ );
+ return win.convert({ populate: true });
+ },
+
+ async update(windowId, updateInfo) {
+ let needResize =
+ updateInfo.left !== null ||
+ updateInfo.top !== null ||
+ updateInfo.width !== null ||
+ updateInfo.height !== null;
+ if (
+ updateInfo.state !== null &&
+ updateInfo.state != "normal" &&
+ needResize
+ ) {
+ throw new ExtensionError(
+ `"state": "${updateInfo.state}" may not be combined with "left", "top", "width", or "height"`
+ );
+ }
+
+ let win = windowManager.get(windowId, context);
+ if (!win) {
+ throw new ExtensionError(`Invalid window ID: ${windowId}`);
+ }
+
+ // Update the window only after it has been fully initialized.
+ if (win.window.webExtensionWindowCreatePending) {
+ await new Promise(resolve => {
+ win.window.addEventListener(
+ "webExtensionWindowCreateDone",
+ resolve,
+ { once: true }
+ );
+ });
+ }
+
+ if (updateInfo.focused) {
+ win.window.focus();
+ }
+
+ if (updateInfo.state !== null) {
+ await win.setState(updateInfo.state);
+ }
+
+ if (updateInfo.drawAttention) {
+ // Bug 1257497 - Firefox can't cancel attention actions.
+ win.window.getAttention();
+ }
+
+ updateGeometry(win.window, updateInfo);
+
+ if (updateInfo.titlePreface !== null) {
+ win.setTitlePreface(updateInfo.titlePreface);
+ if (win instanceof TabmailWindow) {
+ win.window.document.getElementById("tabmail").setDocumentTitle();
+ } else if (win.window.gBrowser?.updateTitlebar) {
+ await win.window.gBrowser.updateTitlebar();
+ }
+ }
+
+ // TODO: All the other properties, focused=false...
+
+ return win.convert();
+ },
+
+ remove(windowId) {
+ let window = windowTracker.getWindow(windowId, context);
+ window.close();
+
+ return new Promise(resolve => {
+ let listener = () => {
+ windowTracker.removeListener("domwindowclosed", listener);
+ resolve();
+ };
+ windowTracker.addListener("domwindowclosed", listener);
+ });
+ },
+ openDefaultBrowser(url) {
+ let uri = null;
+ try {
+ uri = Services.io.newURI(url);
+ } catch (e) {
+ throw new ExtensionError(`Url "${url}" seems to be malformed.`);
+ }
+ if (!uri.schemeIs("http") && !uri.schemeIs("https")) {
+ throw new ExtensionError(
+ `Url scheme "${uri.scheme}" is not supported.`
+ );
+ }
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(uri);
+ },
+ },
+ };
+ }
+};