diff options
Diffstat (limited to 'comm/mail/components/newmailaccount')
6 files changed, 1571 insertions, 0 deletions
diff --git a/comm/mail/components/newmailaccount/content/accountProvisioner.js b/comm/mail/components/newmailaccount/content/accountProvisioner.js new file mode 100644 index 0000000000..14ba69c515 --- /dev/null +++ b/comm/mail/components/newmailaccount/content/accountProvisioner.js @@ -0,0 +1,892 @@ +/* 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"; + +/* globals MsgAccountManager, MozElements */ + +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + PluralForm: "resource://gre/modules/PluralForm.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + AccountCreationUtils: + "resource:///modules/accountcreation/AccountCreationUtils.jsm", + + UIDensity: "resource:///modules/UIDensity.jsm", + UIFontSize: "resource:///modules/UIFontSize.jsm", +}); + +var { gAccountSetupLogger } = AccountCreationUtils; + +// AbortController to handle timeouts and abort the fetch requests. +var gAbortController; +var RETRY_TIMEOUT = 5000; // 5 seconds +var CONNECTION_TIMEOUT = 15000; // 15 seconds +var MAX_SMALL_ADDRESSES = 2; + +// Keep track of the prefers-reduce-motion media query for JS based animations. +var gReducedMotion; + +// The main 3 Pane Window that we need to define on load in order to properly +// update the UI when a new account is created. +var gMainWindow; + +// Define window event listeners. +window.addEventListener("load", () => { + gAccountProvisioner.onLoad(); +}); +window.addEventListener("unload", () => { + gAccountProvisioner.onUnload(); +}); + +// Object to collect all the extra providers attributes to be used when +// building the URL for the API call to purchase an item. +var storedData = {}; + +/** + * Helper method to split a value based on its first available blank space. + * + * @param {string} str - The string to split. + * @returns {Array} - An array with the generated first and last name. + */ +function splitName(str) { + let i = str.lastIndexOf(" "); + if (i >= 1) { + return [str.substring(0, i), str.substring(i + 1)]; + } + return [str, ""]; +} + +/** + * Quick and simple HTML sanitization. + * + * @param {string} inputID - The ID of the currently used input field. + * @returns {string} - The HTML sanitized input value. + */ +function sanitizeName(inputID) { + let div = document.createElement("div"); + div.textContent = document.getElementById(inputID).value; + return div.innerHTML.trim(); +} + +/** + * Replace occurrences of placeholder with the given node + * + * @param aTextContainer {Node} - DOM node containing the text child + * @param aTextNode {Node} - Text node containing the text, child of the aTextContainer + * @param aPlaceholder {String} - String to look for in aTextNode's textContent + * @param aReplacement {Node} - DOM node to insert instead of the found replacement + */ +function insertHTMLReplacement( + aTextContainer, + aTextNode, + aPlaceholder, + aReplacement +) { + if (aTextNode.textContent.includes(aPlaceholder)) { + let placeIndex = aTextNode.textContent.indexOf(aPlaceholder); + let restNode = aTextNode.splitText(placeIndex + aPlaceholder.length); + aTextContainer.insertBefore(aReplacement, restNode); + let placeholderNode = aTextNode.splitText(placeIndex); + placeholderNode.remove(); + } +} + +/** + * This is our controller for the entire account provisioner setup process. + */ +var gAccountProvisioner = { + // If the setup wizard has already been initialized. + _isInited: false, + // If the data fetching of the providers is currently in progress. + _isLoadingProviders: false, + // If the providers have already been loaded. + _isLoadedProviders: false, + // Store a timeout retry in case fetching the providers fails. + _loadProviderRetryId: null, + // Array containing all fetched providers. + allProviders: [], + // Array containing all fetched provider names that only offer email. + mailProviders: [], + // Array containing all fetched provider names that also offer custom domain. + domainProviders: [], + // Handle a timeout to abort the fetch requests. + timeoutId: null, + + /** + * Returns the URL for retrieving suggested names from the selected providers. + */ + get suggestFromName() { + return Services.prefs.getCharPref("mail.provider.suggestFromName"); + }, + + /** + * Initialize the main notification box for the account setup process. + */ + get notificationBox() { + if (!this._notificationBox) { + this._notificationBox = new MozElements.NotificationBox(element => { + element.setAttribute("notificationside", "top"); + document + .getElementById("accountProvisionerNotifications") + .append(element); + }); + } + return this._notificationBox; + }, + + /** + * Clear currently running async fetches and reset important variables. + */ + onUnload() { + this.clearAbortTimeout(); + gAbortController.abort(); + gAbortController = null; + }, + + async onLoad() { + // We can only init once, so bail out if we've been called again. + if (this._isInited) { + return; + } + + gAccountSetupLogger.debug("Initializing provisioner wizard"); + gReducedMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)" + ).matches; + + // Store the main window. + gMainWindow = Services.wm.getMostRecentWindow("mail:3pane"); + + // Initialize the fetch abort controller. + gAbortController = new AbortController(); + + // If we have a name stored, populate the search field with it. + if ("@mozilla.org/userinfo;1" in Cc) { + let userInfo = Cc["@mozilla.org/userinfo;1"].getService(Ci.nsIUserInfo); + // Assume that it's a genuine full name if it includes a space. + if (userInfo.fullname.includes(" ")) { + document.getElementById("mailName").value = userInfo.fullname; + document.getElementById("domainName").value = userInfo.fullname; + } + } + + this.setupEventListeners(); + await this.tryToFetchProviderList(); + + gAccountSetupLogger.debug("Provisioner wizard init complete."); + + // Move the focus on the first available field. + document.getElementById("mailName").focus(); + this._isInited = true; + + Services.telemetry.scalarAdd("tb.account.opened_account_provisioner", 1); + + UIDensity.registerWindow(window); + UIFontSize.registerWindow(window); + }, + + /** + * Set up the event listeners for the static elements in the page. + */ + setupEventListeners() { + document.getElementById("cancelButton").onclick = () => { + window.close(); + }; + + document.getElementById("existingButton").onclick = () => { + window.close(); + gMainWindow.postMessage("open-account-setup-tab", "*"); + }; + + document.getElementById("backButton").onclick = () => { + this.backToSetupView(); + }; + }, + + /** + * Return to the initial view without resetting any existing data. + */ + backToSetupView() { + this.clearAbortTimeout(); + this.clearNotifications(); + + // Clear search results. + let mailResultsArea = document.getElementById("mailResultsArea"); + while (mailResultsArea.hasChildNodes()) { + mailResultsArea.lastChild.remove(); + } + let domainResultsArea = document.getElementById("domainResultsArea"); + while (domainResultsArea.hasChildNodes()) { + domainResultsArea.lastChild.remove(); + } + + // Update the UI to show the initial view. + document.getElementById("mailSearch").hidden = false; + document.getElementById("domainSearch").hidden = false; + document.getElementById("mailSearchResults").hidden = true; + document.getElementById("domainSearchResults").hidden = true; + + // Update the buttons visibility. + document.getElementById("backButton").hidden = true; + document.getElementById("cancelButton").hidden = false; + document.getElementById("existingButton").hidden = false; + + // Move the focus back on the first available field. + document.getElementById("mailName").focus(); + }, + + /** + * Show a loading notification. + */ + async startLoadingState(stringName) { + this.clearNotifications(); + + let notificationMessage = await document.l10n.formatValue(stringName); + + gAccountSetupLogger.debug(`Status msg: ${notificationMessage}`); + + let notification = this.notificationBox.appendNotification( + "accountSetupLoading", + { + label: notificationMessage, + priority: this.notificationBox.PRIORITY_INFO_LOW, + }, + null + ); + notification.setAttribute("align", "center"); + + // Hide the close button to prevent dismissing the notification. + notification.removeAttribute("dismissable"); + + this.ensureVisibleNotification(); + }, + + /** + * Show an error notification in case something went wrong. + * + * @param {string} stringName - The name of the fluent string that needs to + * be attached to the notification. + * @param {boolean} isMsgError - True if the message comes from a server error + * response or try/catch. + */ + async showErrorNotification(stringName, isMsgError) { + gAccountSetupLogger.debug(`Status error: ${stringName}`); + + // Always remove any leftover notification before creating a new one. + this.clearNotifications(); + + // Fetch the fluent string only if this is not an error message coming from + // a previous method. + let notificationMessage = isMsgError + ? stringName + : await document.l10n.formatValue(stringName); + + let notification = this.notificationBox.appendNotification( + "accountProvisionerError", + { + label: notificationMessage, + priority: this.notificationBox.PRIORITY_WARNING_MEDIUM, + }, + null + ); + + // Hide the close button to prevent dismissing the notification. + notification.removeAttribute("dismissable"); + + this.ensureVisibleNotification(); + }, + + async showSuccessNotification(stringName) { + // Always remove any leftover notification before creating a new one. + this.clearNotifications(); + + let notification = this.notificationBox.appendNotification( + "accountProvisionerSuccess", + { + label: await document.l10n.formatValue(stringName), + priority: this.notificationBox.PRIORITY_WARNING_MEDIUM, + }, + null + ); + notification.setAttribute("type", "success"); + // Hide the close button to prevent dismissing the notification. + notification.removeAttribute("dismissable"); + + this.ensureVisibleNotification(); + }, + + /** + * Clear all leftover notifications. + */ + clearNotifications() { + this.notificationBox.removeAllNotifications(); + }, + + /** + * Event handler for when the user selects an address by clicking on the price + * button for that address. This function spawns the content tab for the + * address order form, and then closes the Account Provisioner tab. + * + * @param {string} providerId - The ID of the chosen provider. + * @param {string} email - The chosen email address. + * @param {boolean} [isDomain=false] - If the fetched data comes from a domain + * search form. + */ + onAddressSelected(providerId, email, isDomain = false) { + gAccountSetupLogger.debug("An address was selected by the user."); + let provider = this.allProviders.find(p => p.id == providerId); + + let url = provider.api; + let inputID = isDomain ? "domainName" : "mailName"; + let [firstName, lastName] = splitName(sanitizeName(inputID)); + // Replace the variables in the API url. + url = url.replace("{firstname}", firstName); + url = url.replace("{lastname}", lastName); + url = url.replace("{email}", email); + + // And add the extra data. + let data = storedData[providerId]; + delete data.provider; + for (let name in data) { + url += `${!url.includes("?") ? "?" : "&"}${name}=${encodeURIComponent( + data[name] + )}`; + } + + gAccountSetupLogger.debug("Opening up a contentTab with the order form."); + // Open the checkout content tab. + let mail3Pane = Services.wm.getMostRecentWindow("mail:3pane"); + let tabmail = mail3Pane.document.getElementById("tabmail"); + tabmail.openTab("provisionerCheckoutTab", { + url, + realName: (firstName + " " + lastName).trim(), + email, + }); + + let providerHostname = new URL(url).hostname; + // Collect telemetry on which provider was selected for a new email account. + Services.telemetry.keyedScalarAdd( + "tb.account.selected_account_from_provisioner", + providerHostname, + 1 + ); + + // The user has made a selection. Close the provisioner window and let the + // provider setup process take place in a dedicated tab. + window.close(); + }, + + /** + * Attempt to fetch the provider list from the server. + */ + async tryToFetchProviderList() { + // If we're already in the middle of getting the provider list, or we + // already got it before, bail out. + if (this._isLoadingProviders || this._isLoadedProviders) { + return; + } + + this._isLoadingProviders = true; + + // If there's a timeout ID for waking the account provisioner, clear it. + if (this._loadProviderRetryId) { + window.clearTimeout(this._loadProviderRetryId); + this._loadProviderRetryId = null; + } + + await this.startLoadingState("account-provisioner-fetching-provisioners"); + + let providerListUrl = Services.prefs.getCharPref( + "mail.provider.providerList" + ); + + gAccountSetupLogger.debug( + `Trying to populate provider list from ${providerListUrl}…` + ); + + try { + let res = await fetch(providerListUrl, { + signal: gAbortController.signal, + }); + this.startAbortTimeout(); + let data = await res.json(); + this.populateProvidersLists(data); + } catch (error) { + // Ugh, we couldn't get the JSON file. Maybe we're not online. Or maybe + // the server is down, or the file isn't being served. Regardless, if + // we get here, none of this stuff is going to work. + this._loadProviderRetryId = window.setTimeout( + () => this.tryToFetchProviderList(), + RETRY_TIMEOUT + ); + this._isLoadingProviders = false; + this.showErrorNotification("account-provisioner-connection-issues"); + gAccountSetupLogger.warn(`Failed to populate providers: ${error}`); + } + }, + + /** + * Validate a provider fetched during an API request to be sure we have all + * the necessary fields to complete a setup process. + * + * @param {object} provider - The fetched provider. + * @returns {boolean} - True if all the fields in the provider match the + * required fields. + */ + providerHasCorrectFields(provider) { + let result = true; + + let required = [ + "id", + "label", + "paid", + "languages", + "api", + "tos_url", + "privacy_url", + "sells_domain", + ]; + + for (let field of required) { + let fieldExists = field in provider; + result &= fieldExists; + + if (!fieldExists) { + gAccountSetupLogger.warn( + `A provider did not have the field ${field}, and will be skipped.` + ); + } + } + + return result; + }, + + /** + * Take the fetched providers, create checkboxes, icons and labels, and insert + * them below the corresponding search input. + * + * @param {?object} data - The object containing all fetched providers. + */ + populateProvidersLists(data) { + gAccountSetupLogger.debug("Populating the provider list"); + this.clearAbortTimeout(); + + if (!data || !data.length) { + gAccountSetupLogger.warn( + "The provider list we got back from the server was empty!" + ); + this.showErrorNotification("account-provisioner-connection-issues"); + return; + } + + let mailProviderList = document.getElementById("mailProvidersList"); + let domainProviderList = document.getElementById("domainProvidersList"); + + this.allProviders = data; + this.mailProviders = []; + this.domainProviders = []; + + for (let provider of data) { + if (!this.providerHasCorrectFields(provider)) { + gAccountSetupLogger.warn( + "A provider had incorrect fields, and has been skipped" + ); + continue; + } + + let entry = document.createElement("li"); + entry.setAttribute("id", provider.id); + + if (provider.icon) { + let icon = document.createElement("img"); + icon.setAttribute("src", provider.icon); + icon.setAttribute("alt", ""); + entry.appendChild(icon); + } + + let name = document.createElement("span"); + name.textContent = provider.label; + entry.appendChild(name); + + if (provider.sells_domain) { + domainProviderList.appendChild(entry); + this.domainProviders.push(provider.id); + } else { + mailProviderList.appendChild(entry); + this.mailProviders.push(provider.id); + } + } + + this._isLoadedProviders = true; + this.clearNotifications(); + }, + + /** + * Enable or disable the form fields when a fetch request starts or ends. + * + * @param {boolean} state - True if a fetch request is in progress. + */ + updateSearchingState(state) { + for (let element of document.querySelectorAll(".disable-on-submit")) { + element.disabled = state; + } + }, + + /** + * Search for available email accounts. + * + * @param {DOMEvent} event - The form submit event. + */ + async onMailFormSubmit(event) { + // Always prevent the actual form submission. + event.preventDefault(); + + // Quick HTML sanitization. + let name = sanitizeName("mailName"); + + // Bail out if the user didn't type anything. + if (!name) { + return; + } + + let resultsArea = document.getElementById("mailSearchResults"); + resultsArea.hidden = true; + + this.startLoadingState("account-provisioner-searching-email"); + let data = await this.submitFormRequest(name, this.mailProviders.join(",")); + this.clearAbortTimeout(); + + let count = this.populateSearchResults(data); + if (!count) { + // Bail out if we didn't get any usable data. + gAccountSetupLogger.warn( + "We got nothing back from the server for search results!" + ); + this.showErrorNotification("account-provisioner-searching-error"); + return; + } + + let resultsTitle = document.getElementById("mailResultsTitle"); + let resultsString = await document.l10n.formatValue( + "account-provisioner-results-title", + { count } + ); + // Attach the sanitized search terms to avoid HTML conversion in fluent. + resultsTitle.textContent = `${resultsString} "${name}"`; + + // Hide the domain section. + document.getElementById("domainSearch").hidden = true; + // Show the results area. + resultsArea.hidden = false; + // Update the buttons visibility. + document.getElementById("cancelButton").hidden = true; + document.getElementById("existingButton").hidden = true; + // Show the back button. + document.getElementById("backButton").hidden = false; + }, + + /** + * Search for available domain names. + * + * @param {DOMEvent} event - The form submit event. + */ + async onDomainFormSubmit(event) { + // Always prevent the actual form submission. + event.preventDefault(); + + // Quick HTML sanitization. + let name = sanitizeName("domainName"); + + // Bail out if the user didn't type anything. + if (!name) { + return; + } + + let resultsArea = document.getElementById("domainSearchResults"); + resultsArea.hidden = true; + + this.startLoadingState("account-provisioner-searching-domain"); + let data = await this.submitFormRequest( + name, + this.domainProviders.join(",") + ); + this.clearAbortTimeout(); + + let count = this.populateSearchResults(data, true); + if (!count) { + // Bail out if we didn't get any usable data. + gAccountSetupLogger.warn( + "We got nothing back from the server for search results!" + ); + this.showErrorNotification("account-provisioner-searching-error"); + return; + } + + let resultsTitle = document.getElementById("domainResultsTitle"); + let resultsString = await document.l10n.formatValue( + "account-provisioner-results-title", + { count } + ); + // Attach the sanitized search terms to avoid HTML conversion in fluent. + resultsTitle.textContent = `${resultsString} "${name}"`; + + // Hide the mail section. + document.getElementById("mailSearch").hidden = true; + // Show the results area. + resultsArea.hidden = false; + // Update the buttons visibility. + document.getElementById("cancelButton").hidden = true; + document.getElementById("existingButton").hidden = true; + // Show the back button. + document.getElementById("backButton").hidden = false; + }, + + /** + * Update the UI to show the fetched address data. + * + * @param {object} data - The fetched data from an email or domain search. + * @param {boolean} [isDomain=false] - If the fetched data comes from a domain + * search form. + */ + populateSearchResults(data, isDomain = false) { + if (!data || !data.length) { + return 0; + } + + this.clearNotifications(); + + let resultsArea = isDomain + ? document.getElementById("domainResultsArea") + : document.getElementById("mailResultsArea"); + // Clear previously generated content. + while (resultsArea.hasChildNodes()) { + resultsArea.lastChild.remove(); + } + + // Filter out possible errors or empty lists. + let validData = data.filter( + result => result.succeeded && result.addresses.length + ); + + if (!validData || !validData.length) { + return 0; + } + + let providersList = isDomain ? this.domainProviders : this.mailProviders; + + let count = 0; + for (let provider of validData) { + count += provider.addresses.length; + + // Don't add a provider header if only 1 is currently available. + if (providersList.length > 1) { + let header = document.createElement("h5"); + header.classList.add("result-list-header"); + header.textContent = this.allProviders.find( + p => p.id == provider.provider + ).label; + resultsArea.appendChild(header); + } + + let list = document.createElement("ul"); + + // Only show a chink of addresses if we got a long list. + let isLongList = provider.addresses.length > 5; + let addresses = isLongList + ? provider.addresses.slice(0, 4) + : provider.addresses; + + for (let address of addresses) { + list.appendChild(this.createAddressRow(address, provider, isDomain)); + } + + resultsArea.appendChild(list); + + // If we got more than 5 addresses, create an hidden bug expandable list + // with the rest of the data. + if (isLongList) { + let hiddenList = document.createElement("ul"); + hiddenList.hidden = true; + + for (let address of provider.addresses.slice(5)) { + hiddenList.appendChild( + this.createAddressRow(address, provider, isDomain) + ); + } + + let button = document.createElement("button"); + button.setAttribute("type", "button"); + button.classList.add("btn-link", "self-center"); + document.l10n.setAttributes( + button, + "account-provisioner-all-results-button" + ); + button.onclick = () => { + hiddenList.hidden = false; + button.hidden = true; + }; + + resultsArea.appendChild(button); + resultsArea.appendChild(hiddenList); + } + } + + for (let provider of data) { + delete provider.succeeded; + delete provider.addresses; + delete provider.price; + storedData[provider.provider] = provider; + } + + return count; + }, + + /** + * Create the list item to show the suggested address returned from a search. + * + * @param {object} address - The address returned from the provider search. + * @param {object} provider - The provider from which the address is + * @param {boolean} [isDomain=false] - If the fetched data comes from a domain + * search form. + * available. + * @returns {HTMLLIElement} + */ + createAddressRow(address, provider, isDomain = false) { + let row = document.createElement("li"); + row.classList.add("result-item"); + + let suggestedAddress = address.address || address; + + let button = document.createElement("button"); + button.setAttribute("type", "button"); + button.onclick = () => { + this.onAddressSelected(provider.provider, suggestedAddress, isDomain); + }; + + let leftArea = document.createElement("span"); + leftArea.classList.add("result-data"); + + let name = document.createElement("span"); + name.classList.add("result-name"); + name.textContent = suggestedAddress; + leftArea.appendChild(name); + row.setAttribute("data-label", suggestedAddress); + + let price = document.createElement("span"); + price.classList.add("result-price"); + + // Build the pricing text and handle possible free trials. + if (address.price) { + if (address.price != 0) { + // Some pricing is defined. + document.l10n.setAttributes(price, "account-provision-price-per-year", { + price: address.price, + }); + } else if (address.price == 0) { + // Price is defined by it's zero. + document.l10n.setAttributes(price, "account-provisioner-free-account"); + } + } else if (provider.price && provider.price != 0) { + // We don't have a price for the current result so let's try to use + // the general Provider's price. + document.l10n.setAttributes(price, "account-provision-price-per-year", { + price: provider.price, + }); + } else { + // No price was specified, let's return "Free". + document.l10n.setAttributes(price, "account-provisioner-free-account"); + } + leftArea.appendChild(price); + + button.appendChild(leftArea); + + let img = document.createElement("img"); + document.l10n.setAttributes(img, "account-provisioner-open-in-tab-img"); + img.setAttribute("alt", ""); + img.setAttribute("src", "chrome://global/skin/icons/open-in-new.svg"); + button.appendChild(img); + + row.appendChild(button); + + return row; + }, + + /** + * Fetches a list of suggested email addresses or domain names from a list of + * selected providers. + * + * @param {string} name - The search value typed by the user. + * @param {Array} providers - Array of providers to search for. + * @returns {object} - A list of available emails or domains. + */ + async submitFormRequest(name, providers) { + // If the focused element is disabled by `updateSearchingState`, focus is + // lost. Save the focused element to restore it later. + let activeElement = document.activeElement; + this.updateSearchingState(true); + + let [firstName, lastName] = splitName(name); + let url = `${this.suggestFromName}?first_name=${encodeURIComponent( + firstName + )}&last_name=${encodeURIComponent(lastName)}&providers=${encodeURIComponent( + providers + )}&version=2`; + + let data; + try { + let res = await fetch(url, { signal: gAbortController.signal }); + this.startAbortTimeout(); + data = await res.json(); + } catch (error) { + gAccountSetupLogger.warn(`Failed to fetch address data: ${error}`); + } + + this.updateSearchingState(false); + // Restore focus. + activeElement.focus(); + return data; + }, + + /** + * Start a timeout to abort a fetch request based on a time limit. + */ + startAbortTimeout() { + this.timeoutId = setTimeout(() => { + gAbortController.abort(); + this.showErrorNotification("account-provisioner-connection-timeout"); + gAccountSetupLogger.warn("Connection timed out"); + }, CONNECTION_TIMEOUT); + }, + + /** + * Clear any leftover timeout to prevent an unnecessary fetch abort. + */ + clearAbortTimeout() { + if (this.timeoutId) { + window.clearTimeout(this.timeoutId); + this.timeoutId = null; + } + }, + + /** + * Always ensure the notification area is visible when a new notification is + * created. + */ + ensureVisibleNotification() { + document.getElementById("accountProvisionerNotifications").scrollIntoView({ + behavior: gReducedMotion ? "auto" : "smooth", + block: "start", + inline: "nearest", + }); + }, +}; diff --git a/comm/mail/components/newmailaccount/content/accountProvisioner.xhtml b/comm/mail/components/newmailaccount/content/accountProvisioner.xhtml new file mode 100644 index 0000000000..37f4be1422 --- /dev/null +++ b/comm/mail/components/newmailaccount/content/accountProvisioner.xhtml @@ -0,0 +1,226 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://messenger/skin/accountSetup.css" type="text/css"?> + +<!DOCTYPE html> + +<html id="accountProvisioner" xmlns="http://www.w3.org/1999/xhtml"> + <head> + <title data-l10n-id="account-provisioner-tab-title"></title> + <meta name="color-scheme" content="light dark" /> + <link + rel="icon" + href="chrome://messenger/skin/icons/new/compact/new-mail.svg" + /> + + <link rel="localization" href="branding/brand.ftl" /> + <link rel="localization" href="messenger/accountProvisioner.ftl" /> + + <script + defer="defer" + src="chrome://messenger/content/globalOverlay.js" + ></script> + <script + defer="defer" + src="chrome://global/content/editMenuOverlay.js" + ></script> + <script + defer="defer" + src="chrome://messenger/content/accountUtils.js" + ></script> + <script + defer="defer" + src="chrome://messenger/content/newmailaccount/accountProvisioner.js" + ></script> + </head> + + <body> + <header> + <h1 + id="accountProvisionerTitle" + data-l10n-id="account-provisioner-title" + class="title" + ></h1> + <p + id="accountProvisionerDescription" + data-l10n-id="account-provisioner-description" + class="description" + ></p> + </header> + + <section class="main-container"> + <aside id="setupView" class="column column-wide"> + <section id="mailSearch"> + <h3 + class="service-title" + data-l10n-id="account-provisioner-mail-account-title" + ></h3> + + <p + class="service-description" + data-l10n-id="account-provisioner-mail-account-description" + > + <a + href="https://mailfence.com/" + data-l10n-name="mailfence-home-link" + ></a> + </p> + + <form + id="mailForm" + class="service-form" + onsubmit="gAccountProvisioner.onMailFormSubmit(event);" + > + <div class="service-form-container"> + <input + id="mailName" + type="text" + data-l10n-id="account-provisioner-mail-input" + class="disable-on-submit" + autocomplete="off" + required="required" + /> + <button + type="submit" + class="disable-on-submit" + data-l10n-id="account-provisioner-search-button" + ></button> + </div> + + <ul id="mailProvidersList" class="providers-list"> + <!-- This will be populated in JS. --> + </ul> + </form> + + <div id="mailSearchResults" hidden="hidden"> + <h4 id="mailResultsTitle" class="results-title"></h4> + <section class="provisioner-results-area"> + <div id="mailResultsArea" class="results-list"></div> + </section> + <p + data-l10n-id="account-provisioner-mail-results-caption" + class="tip-caption" + ></p> + </div> + </section> + + <section id="domainSearch"> + <h3 + class="service-title" + data-l10n-id="account-provisioner-domain-title" + ></h3> + + <p + class="service-description" + data-l10n-id="account-provisioner-domain-description" + > + <a href="https://gandi.net/" data-l10n-name="gandi-home-link"></a> + </p> + + <form + id="domainForm" + class="service-form" + onsubmit="gAccountProvisioner.onDomainFormSubmit(event);" + > + <div class="service-form-container"> + <input + id="domainName" + type="text" + data-l10n-id="account-provisioner-domain-input" + class="disable-on-submit" + autocomplete="off" + required="required" + /> + <button + type="submit" + class="disable-on-submit" + data-l10n-id="account-provisioner-search-button" + ></button> + </div> + + <ul id="domainProvidersList" class="providers-list"> + <!-- This will be populated in JS. --> + </ul> + </form> + + <div id="domainSearchResults" hidden="hidden"> + <h4 id="domainResultsTitle" class="results-title"></h4> + <section class="provisioner-results-area"> + <div id="domainResultsArea" class="results-list"></div> + </section> + <p + data-l10n-id="account-provisioner-domain-results-caption" + class="tip-caption" + ></p> + </div> + </section> + + <div + id="accountProvisionerNotifications" + class="account-setup-notifications" + > + <!-- Notifications will be lazily loaded here. --> + </div> + + <section class="action-buttons-container provisioner-buttons"> + <button + id="backButton" + type="button" + data-l10n-id="account-provisioner-button-back" + data-l10n-attrs="accesskey" + hidden="hidden" + ></button> + <button + id="cancelButton" + type="button" + data-l10n-id="account-provisioner-button-cancel" + data-l10n-attrs="accesskey" + ></button> + <button + id="existingButton" + type="button" + data-l10n-id="account-provisioner-button-existing" + data-l10n-attrs="accesskey" + ></button> + </section> + </aside> + <!-- END setupView column --> + + <aside class="column second-column"> + <article id="step1" class="tip-caption"> + <img + src="chrome://messenger/skin/illustrations/octopus-setup.svg" + data-l10n-id="account-provisioner-step1-image" + alt="" + /> + <p data-l10n-id="account-provisioner-start-help"> + <a + href="https://www.mozilla.org/privacy/" + data-l10n-name="mozilla-privacy-link" + ></a> + <a + href="https://mailfence.com/en/privacy.jsp" + data-l10n-name="mailfence-privacy-link" + ></a> + <a + href="https://mailfence.com/en/terms.jsp" + data-l10n-name="mailfence-tou-link" + ></a> + <a + href="https://www.gandi.net/contracts/privacy-policy" + data-l10n-name="gandi-privacy-link" + ></a> + <a + href="https://www.gandi.net/contracts/terms-of-use" + data-l10n-name="gandi-tou-link" + ></a> + </p> + </article> + </aside> + <!-- END second column--> + </section> + </body> +</html> diff --git a/comm/mail/components/newmailaccount/content/provisionerCheckout.js b/comm/mail/components/newmailaccount/content/provisionerCheckout.js new file mode 100644 index 0000000000..0fc38c87e5 --- /dev/null +++ b/comm/mail/components/newmailaccount/content/provisionerCheckout.js @@ -0,0 +1,157 @@ +/* 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/. */ + +// mail/base/content/contentAreaClick.js +/* globals hRefForClickEvent, openLinkExternally */ +// mail/base/content/specialTabs.js +/* globals specialTabs */ + +var { ConsoleAPI } = ChromeUtils.importESModule( + "resource://gre/modules/Console.sys.mjs" +); + +/** + * A content tab for the account provisioner. We use Javascript-y magic to + * "subclass" specialTabs.contentTabType, and then override the appropriate + * members. + * + * Also note that provisionerCheckoutTab is a singleton (hence the maxTabs: 1). + */ +var provisionerCheckoutTabType = Object.create(specialTabs.contentTabType, { + name: { value: "provisionerCheckoutTab" }, + modes: { + value: { + provisionerCheckoutTab: { + type: "provisionerCheckoutTab", + maxTabs: 1, + }, + }, + }, + _log: { + value: new ConsoleAPI({ + prefix: "mail.provider", + maxLogLevel: "warn", + maxLogLevelPref: "mail.provider.loglevel", + }), + }, +}); + +/** + * Here, we're overriding openTab - first we call the openTab of contentTab + * (for the context of this provisionerCheckoutTab "aTab") and then passing + * special arguments "realName", "email" and "searchEngine" from the caller + * of openTab, and passing those to our _setMonitoring function. + */ +provisionerCheckoutTabType.openTab = function (aTab, aArgs) { + specialTabs.contentTabType.openTab.call(this, aTab, aArgs); + + // Since there's only one tab of this type ever (see the mode definition), + // we're OK to stash this stuff here. + this._realName = aArgs.realName; + this._email = aArgs.email; + this._searchEngine = aArgs.searchEngine || ""; + + this._setMonitoring( + aTab.browser, + aArgs.realName, + aArgs.email, + aArgs.searchEngine + ); +}; + +/** + * We're overriding closeTab - first, we call the closeTab of contentTab, + * (for the context of this provisionerCheckoutTab "aTab"), and then we + * unregister our observer that was registered in _setMonitoring. + */ +provisionerCheckoutTabType.closeTab = function (aTab) { + specialTabs.contentTabType.closeTab.call(this, aTab); + this._log.info("Performing account provisioner cleanup"); + this._log.info("Removing httpRequestObserver"); + Services.obs.removeObserver(this._observer, "http-on-examine-response"); + Services.obs.removeObserver( + this._observer, + "http-on-examine-cached-response" + ); + Services.obs.removeObserver(this.quitObserver, "mail-unloading-messenger"); + delete this._observer; + this._log.info("Account provisioner cleanup is done."); +}; + +/** + * Serialize our tab into something we can restore later. + */ +provisionerCheckoutTabType.persistTab = function (aTab) { + return { + tabURI: aTab.browser.currentURI.spec, + realName: this._realName, + email: this._email, + searchEngine: this._searchEngine, + }; +}; + +/** + * Re-open the provisionerCheckoutTab with all of the stuff we stashed in + * persistTab. This will automatically hook up our monitoring again. + */ +provisionerCheckoutTabType.restoreTab = function (aTabmail, aPersistedState) { + aTabmail.openTab("provisionerCheckoutTab", { + url: aPersistedState.tabURI, + realName: aPersistedState.realName, + email: aPersistedState.email, + searchEngine: aPersistedState.searchEngine, + background: true, + }); +}; + +/** + * This function registers an observer to watch for HTTP requests where the + * contentType contains text/xml. + */ +provisionerCheckoutTabType._setMonitoring = function ( + aBrowser, + aRealName, + aEmail, + aSearchEngine +) { + let mail3Pane = Services.wm.getMostRecentWindow("mail:3pane"); + + // We'll construct our special observer (defined in urlListener.js) + // that will watch for requests where the contentType contains + // text/xml. + this._observer = new mail3Pane.httpRequestObserver(aBrowser, { + realName: aRealName, + email: aEmail, + searchEngine: aSearchEngine, + }); + + // Register our observer + Services.obs.addObserver(this._observer, "http-on-examine-response"); + Services.obs.addObserver(this._observer, "http-on-examine-cached-response"); + Services.obs.addObserver(this.quitObserver, "mail-unloading-messenger"); + + this._log.info("httpRequestObserver wired up."); +}; + +/** + * This observer listens for the mail-unloading-messenger event fired by each + * mail window before they unload. If the mail window is the same window that + * this provisionerCheckoutTab belongs to, then we stash a pref so that when + * the session restarts, we go straight to the tab, as opposed to showing the + * dialog again. + */ +provisionerCheckoutTabType.quitObserver = { + observe(aSubject, aTopic, aData) { + // Make sure we saw the right topic, and that the window that is closing + // is the 3pane window that the provisionerCheckoutTab belongs to. + if (aTopic == "mail-unloading-messenger" && aSubject === window) { + // We quit while the provisionerCheckoutTab was opened. Set our sneaky + // pref so that we suppress the dialog on startup. + Services.prefs.setBoolPref( + "mail.provider.suppress_dialog_on_startup", + true + ); + } + }, +}; diff --git a/comm/mail/components/newmailaccount/content/uriListener.js b/comm/mail/components/newmailaccount/content/uriListener.js new file mode 100644 index 0000000000..c4d9177ebe --- /dev/null +++ b/comm/mail/components/newmailaccount/content/uriListener.js @@ -0,0 +1,281 @@ +/* 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/. */ + +/* globals openAccountSetupTabWithAccount, openAccountProvisionerTab */ + +/** + * This object takes care of intercepting page loads and creating the + * corresponding account if the page load turns out to be a text/xml file from + * one of our account providers. + */ + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +var { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm"); +var { JXON } = ChromeUtils.import("resource:///modules/JXON.jsm"); + +/** + * This is an observer that watches all HTTP requests for one where the + * response contentType contains text/xml. Once that observation is + * made, we ensure that the associated window for that request matches + * the window belonging to the content tab for the account order form. + * If so, we attach an nsITraceableListener to read the contents of the + * request response, and react accordingly if the contents can be turned + * into an email account. + * + * @param aBrowser The XUL <browser> the request lives in. + * @param aParams An object containing various bits of information. + * @param aParams.realName The real name of the person + * @param aParams.email The email address the person picked. + * @param aParams.searchEngine The search engine associated to that provider. + */ +function httpRequestObserver(aBrowser, aParams) { + this.browser = aBrowser; + this.params = aParams; +} + +httpRequestObserver.prototype = { + observe(aSubject, aTopic, aData) { + if ( + aTopic != "http-on-examine-response" && + aTopic != "http-on-examine-cached-response" + ) { + return; + } + + if (!(aSubject instanceof Ci.nsIHttpChannel)) { + console.error( + "Failed to get a nsIHttpChannel when " + + "observing http-on-examine-response" + ); + return; + } + // Helper function to get header values. + let getHttpHeader = (httpChannel, header) => { + // getResponseHeader throws when header is not set. + try { + return httpChannel.getResponseHeader(header); + } catch (e) { + return null; + } + }; + + let contentType = getHttpHeader(aSubject, "Content-Type"); + if (!contentType || !contentType.toLowerCase().startsWith("text/xml")) { + return; + } + + // It's possible the account information changed during the setup at the + // provider. Check some headers and set them if needed. + let name = getHttpHeader(aSubject, "x-thunderbird-account-name"); + if (name) { + this.params.realName = name; + } + let email = getHttpHeader(aSubject, "x-thunderbird-account-email"); + if (email) { + this.params.email = email; + } + + let requestWindow = this._getWindowForRequest(aSubject); + if (!requestWindow || requestWindow !== this.browser.innerWindowID) { + return; + } + + // Ok, we've got a request that looks like a decent candidate. + // Let's attach our TracingListener. + if (aSubject instanceof Ci.nsITraceableChannel) { + let newListener = new TracingListener(this.browser, this.params); + newListener.oldListener = aSubject.setNewListener(newListener); + } + }, + + /** + * _getWindowForRequest is an internal function that takes an nsIRequest, + * and returns the associated window for that request. If it cannot find + * an associated window, the function returns null. On exception, the + * exception message is logged to the Error Console and null is returned. + * + * @param aRequest the nsIRequest to analyze + */ + _getWindowForRequest(aRequest) { + try { + if (aRequest && aRequest.notificationCallbacks) { + return aRequest.notificationCallbacks.getInterface(Ci.nsILoadContext) + .currentWindowContext.innerWindowId; + } + if ( + aRequest && + aRequest.loadGroup && + aRequest.loadGroup.notificationCallbacks + ) { + return aRequest.loadGroup.notificationCallbacks.getInterface( + Ci.nsILoadContext + ).currentWindowContext.innerWindowId; + } + } catch (e) { + console.error( + "Could not find an associated window " + + "for an HTTP request. Error: " + + e + ); + } + return null; + }, + + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), +}; + +/** + * TracingListener is an nsITracableChannel implementation that copies + * an incoming stream of data from a request. The data flows through this + * nsITracableChannel transparently to the original listener. Once the + * response data is fully downloaded, an attempt is made to parse it + * as XML, and derive email account data from it. + * + * @param aBrowser The XUL <browser> the request lives in. + * @param aParams An object containing various bits of information. + * @param aParams.realName The real name of the person + * @param aParams.email The email address the person picked. + * @param aParams.searchEngine The search engine associated to that provider. + */ +function TracingListener(aBrowser, aParams) { + this.chunks = []; + this.browser = aBrowser; + this.params = aParams; + this.oldListener = null; +} + +TracingListener.prototype = { + onStartRequest(/* nsIRequest */ aRequest) { + this.oldListener.onStartRequest(aRequest); + }, + + onStopRequest(/* nsIRequest */ aRequest, /* int */ aStatusCode) { + const { CreateInBackend } = ChromeUtils.import( + "resource:///modules/accountcreation/CreateInBackend.jsm" + ); + const { readFromXML } = ChromeUtils.import( + "resource:///modules/accountcreation/readFromXML.jsm" + ); + const { AccountConfig } = ChromeUtils.import( + "resource:///modules/accountcreation/AccountConfig.jsm" + ); + + let newAccount; + try { + // Construct the downloaded data (we'll assume UTF-8 bytes) into XML. + let xml = this.chunks.join(""); + let bytes = new Uint8Array(xml.length); + for (let i = 0; i < xml.length; i++) { + bytes[i] = xml.charCodeAt(i); + } + xml = new TextDecoder().decode(bytes); + + // Attempt to derive email account information. + let domParser = new DOMParser(); + let accountConfig = readFromXML( + JXON.build(domParser.parseFromString(xml, "text/xml")) + ); + AccountConfig.replaceVariables( + accountConfig, + this.params.realName, + this.params.email + ); + + let host = aRequest.getRequestHeader("Host"); + let providerHostname = new URL("http://" + host).hostname; + // Collect telemetry on which provider the new address was purchased from. + Services.telemetry.keyedScalarAdd( + "tb.account.new_account_from_provisioner", + providerHostname, + 1 + ); + + // Create the new account in the back end. + newAccount = CreateInBackend.createAccountInBackend(accountConfig); + + let tabmail = document.getElementById("tabmail"); + // Find the tab associated with this browser, and close it. + let myTabInfo = tabmail.tabInfo.filter( + function (x) { + return "browser" in x && x.browser == this.browser; + }.bind(this) + )[0]; + tabmail.closeTab(myTabInfo); + + // Trigger the first login to download the folder structure and messages. + newAccount.incomingServer.getNewMessages( + newAccount.incomingServer.rootFolder, + this._msgWindow, + null + ); + } catch (e) { + // Something went wrong with account set up. Dump the error out to the + // error console, reopen the account provisioner tab, and show an error + // dialog to the user. + console.error("Problem interpreting provider XML:" + e); + openAccountProvisionerTab(); + Services.prompt.alert(window, null, e); + + this.oldListener.onStopRequest(aRequest, aStatusCode); + return; + } + + // Open the account setup tab and show the success view or an error if we + // weren't able to create the new account. + openAccountSetupTabWithAccount( + newAccount, + this.params.realName, + this.params.email + ); + + this.oldListener.onStopRequest(aRequest, aStatusCode); + }, + + onDataAvailable( + /* nsIRequest */ aRequest, + /* nsIInputStream */ aStream, + /* int */ aOffset, + /* int */ aCount + ) { + // We want to read the stream of incoming data, but we also want + // to make sure it gets passed to the original listener. We do this + // by passing the input stream through an nsIStorageStream, writing + // the data to that stream, and passing it along to the next listener. + let binaryInputStream = Cc[ + "@mozilla.org/binaryinputstream;1" + ].createInstance(Ci.nsIBinaryInputStream); + let storageStream = Cc["@mozilla.org/storagestream;1"].createInstance( + Ci.nsIStorageStream + ); + let outStream = Cc["@mozilla.org/binaryoutputstream;1"].createInstance( + Ci.nsIBinaryOutputStream + ); + + binaryInputStream.setInputStream(aStream); + + // The segment size of 8192 is a little magical - more or less + // copied from nsITraceableChannel example code strewn about the + // web. + storageStream.init(8192, aCount, null); + outStream.setOutputStream(storageStream.getOutputStream(0)); + + let data = binaryInputStream.readBytes(aCount); + this.chunks.push(data); + + outStream.writeBytes(data, aCount); + this.oldListener.onDataAvailable( + aRequest, + storageStream.newInputStream(0), + aOffset, + aCount + ); + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIStreamListener", + "nsIRequestObserver", + ]), +}; diff --git a/comm/mail/components/newmailaccount/jar.mn b/comm/mail/components/newmailaccount/jar.mn new file mode 100644 index 0000000000..23554a9584 --- /dev/null +++ b/comm/mail/components/newmailaccount/jar.mn @@ -0,0 +1,9 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +messenger.jar: + content/messenger/newmailaccount/accountProvisioner.xhtml (content/accountProvisioner.xhtml) + content/messenger/newmailaccount/accountProvisioner.js (content/accountProvisioner.js) + content/messenger/newmailaccount/provisionerCheckout.js (content/provisionerCheckout.js) + content/messenger/newmailaccount/uriListener.js (content/uriListener.js) diff --git a/comm/mail/components/newmailaccount/moz.build b/comm/mail/components/newmailaccount/moz.build new file mode 100644 index 0000000000..de5cd1bf81 --- /dev/null +++ b/comm/mail/components/newmailaccount/moz.build @@ -0,0 +1,6 @@ +# vim: set filetype=python: +# 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/. + +JAR_MANIFESTS += ["jar.mn"] |