From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- .../mail/components/extensions/parent/.eslintrc.js | 81 + .../components/extensions/parent/ext-accounts.js | 283 ++ .../extensions/parent/ext-addressBook.js | 1587 +++++++++++ .../extensions/parent/ext-browserAction.js | 329 +++ .../parent/ext-chrome-settings-overrides.js | 365 +++ .../components/extensions/parent/ext-cloudFile.js | 804 ++++++ .../components/extensions/parent/ext-commands.js | 103 + .../components/extensions/parent/ext-compose.js | 1703 ++++++++++++ .../extensions/parent/ext-composeAction.js | 154 ++ .../extensions/parent/ext-extensionScripts.js | 185 ++ .../components/extensions/parent/ext-folders.js | 675 +++++ .../components/extensions/parent/ext-identities.js | 360 +++ comm/mail/components/extensions/parent/ext-mail.js | 2883 ++++++++++++++++++++ .../components/extensions/parent/ext-mailTabs.js | 485 ++++ .../mail/components/extensions/parent/ext-menus.js | 1544 +++++++++++ .../extensions/parent/ext-messageDisplay.js | 348 +++ .../extensions/parent/ext-messageDisplayAction.js | 251 ++ .../components/extensions/parent/ext-messages.js | 1563 +++++++++++ .../components/extensions/parent/ext-sessions.js | 62 + .../components/extensions/parent/ext-spaces.js | 364 +++ .../extensions/parent/ext-spacesToolbar.js | 308 +++ comm/mail/components/extensions/parent/ext-tabs.js | 822 ++++++ .../mail/components/extensions/parent/ext-theme.js | 543 ++++ .../components/extensions/parent/ext-windows.js | 555 ++++ 24 files changed, 16357 insertions(+) create mode 100644 comm/mail/components/extensions/parent/.eslintrc.js create mode 100644 comm/mail/components/extensions/parent/ext-accounts.js create mode 100644 comm/mail/components/extensions/parent/ext-addressBook.js create mode 100644 comm/mail/components/extensions/parent/ext-browserAction.js create mode 100644 comm/mail/components/extensions/parent/ext-chrome-settings-overrides.js create mode 100644 comm/mail/components/extensions/parent/ext-cloudFile.js create mode 100644 comm/mail/components/extensions/parent/ext-commands.js create mode 100644 comm/mail/components/extensions/parent/ext-compose.js create mode 100644 comm/mail/components/extensions/parent/ext-composeAction.js create mode 100644 comm/mail/components/extensions/parent/ext-extensionScripts.js create mode 100644 comm/mail/components/extensions/parent/ext-folders.js create mode 100644 comm/mail/components/extensions/parent/ext-identities.js create mode 100644 comm/mail/components/extensions/parent/ext-mail.js create mode 100644 comm/mail/components/extensions/parent/ext-mailTabs.js create mode 100644 comm/mail/components/extensions/parent/ext-menus.js create mode 100644 comm/mail/components/extensions/parent/ext-messageDisplay.js create mode 100644 comm/mail/components/extensions/parent/ext-messageDisplayAction.js create mode 100644 comm/mail/components/extensions/parent/ext-messages.js create mode 100644 comm/mail/components/extensions/parent/ext-sessions.js create mode 100644 comm/mail/components/extensions/parent/ext-spaces.js create mode 100644 comm/mail/components/extensions/parent/ext-spacesToolbar.js create mode 100644 comm/mail/components/extensions/parent/ext-tabs.js create mode 100644 comm/mail/components/extensions/parent/ext-theme.js create mode 100644 comm/mail/components/extensions/parent/ext-windows.js (limited to 'comm/mail/components/extensions/parent') 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} - 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} - 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 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 -spacesButton-. + * + * @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 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} + */ + *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} + */ +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} + */ +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 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} - 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} + */ +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 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 . + 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} - 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} 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); + }, + }, + }; + } +}; -- cgit v1.2.3