diff options
Diffstat (limited to 'comm/mail/components/accountcreation/content/accountSetup.js')
-rw-r--r-- | comm/mail/components/accountcreation/content/accountSetup.js | 3023 |
1 files changed, 3023 insertions, 0 deletions
diff --git a/comm/mail/components/accountcreation/content/accountSetup.js b/comm/mail/components/accountcreation/content/accountSetup.js new file mode 100644 index 0000000000..3a214f2292 --- /dev/null +++ b/comm/mail/components/accountcreation/content/accountSetup.js @@ -0,0 +1,3023 @@ +/* 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 MozElements */ + +/* import-globals-from ../../../../mailnews/base/prefs/content/accountUtils.js */ +var { AccountCreationUtils } = ChromeUtils.import( + "resource:///modules/accountcreation/AccountCreationUtils.jsm" +); +var { fetchConfigFromExchange, getAddonsList } = ChromeUtils.import( + "resource:///modules/accountcreation/ExchangeAutoDiscover.jsm" +); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + AccountConfig: "resource:///modules/accountcreation/AccountConfig.jsm", + cal: "resource:///modules/calendar/calUtils.jsm", + CardDAVUtils: "resource:///modules/CardDAVUtils.jsm", + ConfigVerifier: "resource:///modules/accountcreation/ConfigVerifier.jsm", + CreateInBackend: "resource:///modules/accountcreation/CreateInBackend.jsm", + FetchConfig: "resource:///modules/accountcreation/FetchConfig.jsm", + GuessConfig: "resource:///modules/accountcreation/GuessConfig.jsm", + OAuth2Providers: "resource:///modules/OAuth2Providers.jsm", + Sanitizer: "resource:///modules/accountcreation/Sanitizer.jsm", + UIDensity: "resource:///modules/UIDensity.jsm", + UIFontSize: "resource:///modules/UIFontSize.jsm", +}); + +var { + Abortable, + AddonInstaller, + alertPrompt, + assert, + CancelledException, + Exception, + gAccountSetupLogger, + NotReached, + PriorityOrderAbortable, + UserCancelledException, +} = AccountCreationUtils; + +/** + * This is the dialog opened by menu File | New account | Mail... . + * + * It gets the user's realname, email address and password, + * and tries to automatically configure the account from that, + * using various mechanisms. If all fails, the user can enter/edit + * the config, then we create the account. + * + * Steps: + * - User enters realname, email address and password + * - check for config files on disk + * (shipping with Thunderbird, for enterprise deployments) + * - (if fails) try to get the config file from the ISP via a + * fixed URL on the domain of the email address + * - (if fails) try to get the config file from our own database + * at MoMo servers, maintained by the community + * - (if fails) try to guess the config, by guessing hostnames, + * probing ports, checking config via server's CAPS line etc.. + * - verify the setup, by trying to login to the configured servers + * - let user verify and maybe edit the server names and ports + * - If user clicks OK, create the account + */ + +// 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 standard incoming port numbers. +var gStandardPorts = { + imap: [143, 993], + pop3: [110, 995], + smtp: [587, 25, 465], // order matters + exchange: [443], +}; + +// Store all ports into a flat array for greppability. +var gAllStandardPorts = gStandardPorts.smtp + .concat(gStandardPorts.imap) + .concat(gStandardPorts.pop3) + .concat(gStandardPorts.exchange); + +// Define window event listeners. +window.addEventListener("load", () => { + gAccountSetup.onLoad(); +}); +window.addEventListener("unload", () => { + gAccountSetup.onUnload(); +}); + +function onSetupComplete() { + // Post a message to the main window at the end of a successful account setup. + gMainWindow.postMessage("account-created", "*"); +} + +/** + * Prompt a native HTML confirmation dialog for the Exchange auto discover. + * + * @param {string} domain - Text with the question. + * @param {Function} okCallback - Called when the user clicks OK. + * @param {function(ex)} cancelCallback - Called when the user clicks Cancel + * or if you call `Abortable.cancel()`. + * @returns {Abortable} - If `Abortable.cancel()` is called, + * the dialog is closed and the `cancelCallback()` is called. + */ +function confirmExchange(domain, okCallback, cancelCallback) { + let dialog = document.getElementById("exchangeDialog"); + + document.l10n.setAttributes( + document.getElementById("exchangeDialogQuestion"), + "exchange-dialog-question", + { + domain, + } + ); + + document.getElementById("exchangeDialogConfirmButton").onclick = () => { + dialog.close(); + okCallback(); + }; + + document.getElementById("exchangeDialogCancelButton").onclick = () => { + dialog.close(); + cancelCallback(new UserCancelledException()); + }; + + // Show the dialog. + dialog.showModal(); + + let abortable = new Abortable(); + abortable.cancel = ex => { + dialog.close(); + cancelCallback(ex); + }; + return abortable; +} + +/** + * This is our controller for the entire account setup workflow. + */ +var gAccountSetup = { + // Boolean attribute to keep track of the initialization status of the wizard. + isInited: false, + // Attribute to store methods to interrupt abortable operations like testing + // a server configuration or installing an add-on. + _abortable: null, + + tabMonitor: { + monitorName: "accountSetupMonitor", + + onTabTitleChanged() {}, + onTabOpened() {}, + onTabPersist() {}, + onTabRestored() {}, + onTabClosing(tab) { + if (tab?.urlbar?.value == "about:accountsetup") { + gMainWindow?.postMessage("account-setup-dismissed", "*"); + } + }, + onTabSwitched() {}, + }, + + /** + * Initialize the main notification box for the account setup process. + */ + get notificationBox() { + if (!this._notificationBox) { + this._notificationBox = new MozElements.NotificationBox(element => { + element.setAttribute("notificationside", "bottom"); + document.getElementById("accountSetupNotifications").append(element); + }); + } + return this._notificationBox; + }, + + /** + * Initialize the notification box for the calendar and address book sync + * process at the end of the account setup. + */ + get syncingBox() { + if (!this._syncingBox) { + this._syncingBox = new MozElements.NotificationBox(element => { + element.setAttribute("notificationside", "bottom"); + document.getElementById("syncNotifications").append(element); + }); + } + return this._syncingBox; + }, + + clearNotifications() { + this.notificationBox.removeAllNotifications(); + }, + + onLoad() { + // Bail out if it was already initialized. + if (this.isInited) { + return; + } + + gAccountSetupLogger.debug("Initializing setup wizard"); + gReducedMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)" + ).matches; + + // Store the main window. + gMainWindow = Services.wm.getMostRecentWindow("mail:3pane"); + + // this._currentConfig is the config we got either from the XML file or from + // guessing or from the user. Unless it's from the user, it contains + // placeholders like %EMAILLOCALPART% in username and other fields. + // + // The config here must retain these placeholders, to be able to adapt when + // the user enters a different realname, or password or email local part. + // A change of the domain name will trigger a new detection anyways. That + // means, before you actually use the config (e.g. to create an account or + // to show it to the user), you need to run replaceVariables(). + this._currentConfig = null; + this._domain = ""; + this._hostname = ""; + this._email = ""; + this._realname = ""; + 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(" ")) { + this._realname = userInfo.fullname; + document.getElementById("realname").value = this._realname; + } + } + + this._password = ""; + // Keep track of the state of the password field, if password or clear text. + this._showPassword = false; + // This is used only for Exchange AutoDiscover and only if needed. + this._exchangeUsername = ""; + // Store the successful callback in this attribute so we can send it around + // the various validation methods. + this._okCallback = onSetupComplete; + this._msgWindow = gMainWindow.msgWindow; + + // If the account provisioner is preffed off, don't display the account + // provisioner button. + if (!Services.prefs.getBoolPref("mail.provider.enabled")) { + document.getElementById("provisionerButton").hidden = true; + } + + // Disable the remember password checkbox if the pref is false. + if (!Services.prefs.getBoolPref("signon.rememberSignons")) { + let passwordCheckbox = document.getElementById("rememberPassword"); + passwordCheckbox.checked = false; + passwordCheckbox.disabled = true; + } + + // Ensure the cursor is on the first input field. + document.getElementById("realname").focus(); + + // In a new profile, the first request to live.thunderbird.net is much + // slower because of one-time overheads like DNS and OCSP. Let's create some + // dummy requests to prime the connections. + let autoconfigURL = Services.prefs.getCharPref("mailnews.auto_config_url"); + fetch(autoconfigURL, { method: "OPTIONS" }).catch(console.error); + + let addonsURL = Services.prefs.getCharPref( + "mailnews.auto_config.addons_url" + ); + if (new URL(autoconfigURL).origin != new URL(addonsURL).origin) { + fetch(addonsURL, { method: "OPTIONS" }).catch(console.error); + } + + // The tab monitor will inform us when this tab is getting closed. + gMainWindow.document + .getElementById("tabmail") + .registerTabMonitor(this.tabMonitor); + + // We did everything, now we can update the variable. + this.isInited = true; + gAccountSetupLogger.debug("Account setup tab loaded."); + + UIDensity.registerWindow(window); + UIFontSize.registerWindow(window); + }, + + /** + * Changes the window configuration to the different modes we have. + * Shows/hides various window parts and buttons. + * + * @param {string} modename + * "start" : Just the realname, email address, password fields + * "find-config" : detection step, adds the loading notification + * "result" : We found a config and display it to the user. + * The user may create the account. + * "manual-edit" : The user wants (or needs) to manually enter their + * the server hostname and other settings. We'll use them as provided. + * Additionally, there are the following sub-modes which can be entered after + * you entered the main mode: + * "manual-edit-have-hostname" : user entered a hostname for both servers + * that we can use + * "manual-edit-testing" : User pressed the [Re-test] button and + * we're currently detecting the "Auto" values + * "manual-edit-complete" : user entered (or we tested) all necessary + * values, and we're ready to create to account + * Currently, this doesn't cover the warning dialogs etc.. It may later. + */ + switchToMode(modename) { + // Bail out if we requested the same mode we're currently viewing. + if (modename == this._currentModename) { + return; + } + + this._currentModename = modename; + gAccountSetupLogger.debug(`switching to UI mode ${modename}`); + + let continueButton = document.getElementById("continueButton"); + let createButton = document.getElementById("createButton"); + let reTestButton = document.getElementById("reTestButton"); + let autoconfigDesc = document.getElementById("manualConfigDescription"); + let setupTitle = document.getElementById("accountSetupTitle"); + + switch (modename) { + case "start": + this.clearNotifications(); + document.getElementById("setupView").hidden = false; + document.getElementById("successView").hidden = true; + + document.l10n.setAttributes(setupTitle, "account-setup-title"); + setupTitle.classList.remove("success"); + document.l10n.setAttributes( + document.getElementById("accountSetupDescription"), + "account-setup-description" + ); + + document.getElementById("resultsArea").hidden = true; + document.getElementById("manualConfigArea").hidden = true; + document.getElementById("manualConfigButton").hidden = true; + document.getElementById("stopButton").hidden = true; + + reTestButton.hidden = true; + autoconfigDesc.hidden = true; + createButton.hidden = true; + continueButton.disabled = true; + continueButton.hidden = false; + break; + case "find-config": + document.getElementById("resultsArea").hidden = true; + document.getElementById("manualConfigArea").hidden = true; + document.getElementById("manualConfigButton").hidden = true; + document.getElementById("stopButton").hidden = false; + + reTestButton.hidden = true; + autoconfigDesc.hidden = true; + createButton.hidden = true; + continueButton.disabled = true; + continueButton.hidden = false; + this.onStop = this.onStopFindConfig; + break; + case "result": + document.getElementById("manualConfigArea").hidden = true; + document.getElementById("stopButton").hidden = true; + document.getElementById("resultsArea").hidden = false; + document.getElementById("manualConfigButton").hidden = false; + + reTestButton.hidden = true; + autoconfigDesc.hidden = true; + continueButton.hidden = true; + createButton.hidden = false; + createButton.disabled = false; + break; + case "manual-edit": + document.getElementById("resultsArea").hidden = true; + document.getElementById("stopButton").hidden = true; + document.getElementById("manualConfigButton").hidden = true; + document.getElementById("manualConfigArea").hidden = false; + + continueButton.hidden = true; + reTestButton.hidden = false; + autoconfigDesc.hidden = false; + reTestButton.disabled = true; + createButton.hidden = false; + createButton.disabled = true; + break; + case "manual-edit-have-hostname": + document.getElementById("resultsArea").hidden = true; + document.getElementById("stopButton").hidden = true; + document.getElementById("manualConfigButton").hidden = true; + document.getElementById("manualConfigArea").hidden = false; + + reTestButton.hidden = false; + autoconfigDesc.hidden = false; + reTestButton.disabled = false; + continueButton.hidden = true; + createButton.hidden = false; + createButton.disabled = true; + break; + case "manual-edit-testing": + document.getElementById("resultsArea").hidden = true; + document.getElementById("manualConfigArea").hidden = false; + document.getElementById("manualConfigButton").hidden = true; + document.getElementById("stopButton").hidden = false; + + reTestButton.hidden = false; + autoconfigDesc.hidden = false; + reTestButton.disabled = true; + continueButton.hidden = true; + createButton.hidden = false; + createButton.disabled = true; + + this.onStop = this.onStopHalfManualTesting; + break; + case "manual-edit-complete": + document.getElementById("resultsArea").hidden = true; + document.getElementById("manualConfigArea").hidden = false; + document.getElementById("manualConfigButton").hidden = true; + document.getElementById("stopButton").hidden = true; + + reTestButton.hidden = false; + autoconfigDesc.hidden = false; + reTestButton.disabled = false; + continueButton.hidden = true; + createButton.disabled = false; + createButton.hidden = false; + + document.getElementById("incomingProtocol").focus(); + break; + case "success": + document.getElementById("setupView").hidden = true; + document.getElementById("successView").hidden = false; + + document.l10n.setAttributes(setupTitle, "account-setup-success-title"); + setupTitle.classList.add("success"); + document.l10n.setAttributes( + document.getElementById("accountSetupDescription"), + "account-setup-success-description" + ); + document.l10n.setAttributes( + document.getElementById("accountSetupDescriptionSecondary"), + "account-setup-success-secondary-description" + ); + break; + default: + throw new NotReached("Unknown mode requested"); + } + + // If we're offline, we're going to disable the create button, but enable + // the advanced config button if we have a current config. + if (Services.io.offline && !this._currentConfig) { + document.getElementById("manualConfigButton").hidden = true; + reTestButton.hidden = true; + autoconfigDesc.hidden = true; + createButton.hidden = true; + } + }, + + /** + * Reset the form and the entire UI of the account setup. + */ + resetSetup() { + document.getElementById("form").reset(); + document.getElementById("realname").focus(); + // Call onStartOver only after resetting the form in order to properly + // update the form buttons. + this.onStartOver(); + }, + + /** + * Start from beginning with possibly new email address. + */ + onStartOver() { + this._currentConfig = null; + if (this._abortable) { + this.onStop(); + } + this.switchToMode("start"); + this.checkValidForm(); + }, + + getConcreteConfig() { + let result = this._currentConfig.copy(); + + AccountConfig.replaceVariables( + result, + this._realname, + this._email, + this._password + ); + result.rememberPassword = + document.getElementById("rememberPassword").checked && !!this._password; + + if (result.incoming.addonAccountType) { + result.incoming.type = result.incoming.addonAccountType; + } + + return result; + }, + + /** + * onInputEmail and onInputRealname are called on input = keypresses, and + * enable/disable the next button based on whether there's a semi-proper + * e-mail address and non-blank realname to start with. + * + * A change to the email address also automatically restarts the + * whole process. + */ + onInputEmail() { + this._email = document.getElementById("email").value; + this.onStartOver(); + }, + + onInputRealname() { + this._realname = document.getElementById("realname").value; + this.checkValidForm(); + }, + + onInputUsername() { + this._exchangeUsername = document.getElementById("usernameEx").value; + this.onStartOver(); + }, + + onInputPassword() { + this._password = document.getElementById("password").value; + this.onStartOver(); + + // Show the password toggle button only if the field is not empty. + let toggleButton = document.getElementById("passwordToggleButton"); + toggleButton.hidden = !this._password; + + if (!this._password) { + // Always reset the field to the proper type. + this.hidePassword(); + } + }, + + /** + * Toggle the type of the password field between password and text to allow + * users reading their own password. + */ + passwordToggle(event) { + // Prevent the form submission if the user presses Enter. + event.preventDefault(); + + // The password field is in plain text, change it back to the proper type. + if (this._showPassword) { + this.hidePassword(); + return; + } + + // Change the password field to plain text to make the text visible. + this.showPassword(); + }, + + /** + * Convert the password field into a plain text field allowing users and + * assistive technologies to read the typed text. + */ + showPassword() { + document.getElementById("password").type = "text"; + document.l10n.setAttributes( + document.getElementById("passwordToggleButton"), + "account-setup-password-toggle-hide" + ); + + let toggleImage = document.getElementById("passwordInfo"); + toggleImage.src = "chrome://messenger/skin/icons/new/compact/eye.svg"; + toggleImage.classList.add("password-toggled"); + + this._showPassword = true; + }, + + /** + * Convert the password field back to its default password type. + */ + hidePassword() { + // No need to reset anything if the password was never shown. + if (!this._showPassword) { + return; + } + + document.getElementById("password").type = "password"; + document.l10n.setAttributes( + document.getElementById("passwordToggleButton"), + "account-setup-password-toggle-show" + ); + + let toggleImage = document.getElementById("passwordInfo"); + toggleImage.src = "chrome://messenger/skin/icons/new/compact/hidden.svg"; + toggleImage.classList.remove("password-toggled"); + + this._showPassword = false; + }, + + /** + * Check whether the user entered the minimum amount of information needed to + * leave the "start" mode (name and email) and is allowed to proceed to the + * detection step. + */ + checkValidForm() { + let email = document.getElementById("email"); + let isValidForm = + email.checkValidity() && + document.getElementById("realname").checkValidity(); + this._domain = isValidForm ? this._email.split("@")[1].toLowerCase() : ""; + + document.getElementById("continueButton").disabled = !isValidForm; + document.getElementById("manualConfigButton").hidden = !isValidForm; + document.getElementById("provisionerButton").hidden = email.value; + }, + + /** + * When the [Continue] button is clicked, we move from the initial account + * information stage to using that information to configure account details. + */ + onContinue() { + this.findConfig(this._domain, this._email); + }, + + // -------------- + // Detection step + + /** + * Try to find an account configuration for this email address. + * This is the function which runs the autoconfig. + */ + findConfig(domain, emailAddress) { + gAccountSetupLogger.debug("findConfig()"); + if (this._abortable) { + this.onStop(); + } + this.switchToMode("find-config"); + this.startLoadingState("account-setup-looking-up-settings"); + + let self = this; + let call = null; + let fetch = null; + + let priority = (this._abortable = new PriorityOrderAbortable( + function (config, call) { + // success + self._abortable = null; + self.stopLoadingState(call.foundMsg); + self.foundConfig(config); + }, + function (e, allErrors) { + // all failed + if (e instanceof CancelledException) { + self.onStartOver(); + return; + } + + // guess config + let initialConfig = new AccountConfig(); + self._prefillConfig(initialConfig); + self._guessConfig(domain, initialConfig); + } + )); + + try { + call = priority.addCall(); + gAccountSetupLogger.debug( + "Looking up configuration: Thunderbird installation…" + ); + call.foundMsg = "account-setup-success-settings-disk"; + fetch = FetchConfig.fromDisk( + domain, + call.successCallback(), + call.errorCallback() + ); + call.setAbortable(fetch); + + call = priority.addCall(); + gAccountSetupLogger.debug("Looking up configuration: Email provider…"); + call.foundMsg = "account-setup-success-settings-isp"; + fetch = FetchConfig.fromISP( + domain, + emailAddress, + call.successCallback(), + call.errorCallback() + ); + call.setAbortable(fetch); + + call = priority.addCall(); + gAccountSetupLogger.debug( + "Looking up configuration: Thunderbird installation…" + ); + call.foundMsg = "account-setup-success-settings-db"; + fetch = FetchConfig.fromDB( + domain, + call.successCallback(), + call.errorCallback() + ); + call.setAbortable(fetch); + + call = priority.addCall(); + gAccountSetupLogger.debug( + "Looking up configuration: Incoming mail domain…" + ); + // "account-setup-success-settings-db" is correct. + // We display the same message for both db and mx cases. + call.foundMsg = "account-setup-success-settings-db"; + fetch = FetchConfig.forMX( + domain, + call.successCallback(), + call.errorCallback() + ); + call.setAbortable(fetch); + + call = priority.addCall(); + gAccountSetupLogger.debug("Looking up configuration: Exchange server…"); + call.foundMsg = "account-setup-success-settings-exchange"; + fetch = fetchConfigFromExchange( + domain, + emailAddress, + this._exchangeUsername, + this._password, + confirmExchange, + call.successCallback(), + (e, allErrors) => { + // Must call error callback in any case to stop the discover mode. + let errorCallback = call.errorCallback(); + if (e instanceof CancelledException) { + errorCallback(e); + } else if (allErrors && allErrors.some(e => e.code == 401)) { + // Auth failed. + // Ask user for username. + this.onStartOver(); + this.stopLoadingState(); // clears status message + document.getElementById("usernameRow").hidden = false; + + this.showErrorNotification( + !this._exchangeUsername + ? "account-setup-credentials-incomplete" + : "account-setup-credentials-wrong" + ); + document.getElementById("manualConfigButton").hidden = false; + errorCallback(new CancelledException()); + } else { + errorCallback(e); + } + } + ); + call.setAbortable(fetch); + } catch (e) { + this.onStop(); + // e.g. when entering an invalid domain like "c@c.-com" + this.showErrorNotification(e, true); + } + }, + + /** + * Just a continuation of findConfig() + */ + _guessConfig(domain, initialConfig) { + this.startLoadingState("account-setup-looking-up-settings-guess"); + let self = this; + self._abortable = GuessConfig.guessConfig( + domain, + function (type, hostname, port, socketType, done, config) { + // progress + gAccountSetupLogger.debug( + `${hostname}:${port} socketType=${socketType} ${type}: progress callback` + ); + }, + function (config) { + // success + self._abortable = null; + self.foundConfig(config); + self.stopLoadingState( + Services.io.offline + ? "account-setup-success-guess-offline" + : "account-setup-success-guess" + ); + }, + function (e, config) { + // guessconfig failed + if (e instanceof CancelledException) { + return; + } + self._abortable = null; + gAccountSetupLogger.warn(`guessConfig failed: ${e}`); + self.showErrorNotification("account-setup-find-settings-failed"); + self.editConfigDetails(); + }, + initialConfig, + "both" + ); + }, + + /** + * Called after findConfig() is successful and displays the data to the user. + * + * @param {AccountConfig} config - The config to present to the user. + */ + foundConfig(config) { + gAccountSetupLogger.debug("found config:\n" + config); + assert( + config instanceof AccountConfig, + "BUG: Arg 'config' needs to be an AccountConfig object" + ); + + this._haveValidConfigForDomain = this._email.split("@")[1]; + + // Bail out if the name and email fields are empty. + if (!this._realname || !this._email) { + return; + } + + config.addons = []; + let successCallback = () => { + this._abortable = null; + this.displayConfigResult(config); + this.switchToMode("result"); + this.ensureVisibleButtons(); + }; + this._abortable = getAddonsList(config, successCallback, e => { + successCallback(); + this.showErrorNotification(e, true); + }); + }, + + /** + * [Stop] button click handler. + * This allows the user to abort any longer operation, esp. network activity. + * We currently have 3 such cases here: + * 1. findConfig(), i.e. fetch config from DB, guessConfig etc. + * 2. testManualConfig(), i.e. the [Retest] button in manual config. + * 3. verifyConfig() - We can't stop this yet, so irrelevant here currently. + * Given that these need slightly different actions, this function will be set + * to a function (i.e. overwritten) by whoever enables the stop button. + * + * We also call this from the code when the user started a different action + * without explicitly clicking [Stop] for the old one first. + */ + onStop() { + throw new NotReached("onStop should be overridden by now"); + }, + + _onStopCommon() { + if (!this._abortable) { + throw new NotReached("onStop called although there's nothing to stop"); + } + gAccountSetupLogger.debug("onStop cancelled _abortable"); + this._abortable.cancel(new UserCancelledException()); + this._abortable = null; + this.stopLoadingState(); + }, + + onStopFindConfig() { + this._onStopCommon(); + this.switchToMode("start"); + this.checkValidForm(); + }, + + onStopHalfManualTesting() { + this._onStopCommon(); + this.validateManualEditComplete(); + }, + + // ----------- Loading area ----------- + /** + * Disable all the input fields of the main form to prevent editing and show + * a notification while a loading or fetching state. + * + * @param {string} stringName - The name of the fluent string that needs to be + * attached to the notification. + */ + async startLoadingState(stringName) { + gAccountSetupLogger.debug(`Loading start: ${stringName}`); + + this.showHelperImage("step2"); + + // Disable all input fields. + for (let input of document.querySelectorAll("#form input")) { + input.disabled = true; + } + + let notificationMessage = await document.l10n.formatValue(stringName); + + gAccountSetupLogger.debug(`Status msg: ${notificationMessage}`); + + let notification = this.notificationBox.getNotificationWithValue( + "accountSetupLoading" + ); + + // If a notification already exists, simply update the message. + if (notification) { + notification.label = notificationMessage; + this.ensureVisibleNotification(); + return; + } + + 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(); + }, + + /** + * Update the text of a currently visible loading notification + * + * @param {string} stringName - The name of the fluent string that needs to be + * attached to the notification. + */ + async updateLoadingState(stringName) { + let notification = this.notificationBox.getNotificationWithValue( + "accountSetupLoading" + ); + // If a notification doesn't already exist, create one. + if (!notification) { + this.startLoadingState(stringName); + return; + } + + let notificationMessage = await document.l10n.formatValue(stringName); + notification.label = notificationMessage; + this.ensureVisibleNotification(); + + gAccountSetupLogger.debug(`Status msg: ${notificationMessage}`); + }, + + /** + * Clear the loading notification and show a successful notification if + * needed. + * + * @param {?string} stringName - The name of the fluent string that needs to + * be attached to the notification, or null if nothing needs to be showed. + */ + async stopLoadingState(stringName) { + // Re-enable all form input fields. + for (let input of document.querySelectorAll("#form input")) { + input.removeAttribute("disabled"); + } + + // Always remove any leftover notification. + this.clearNotifications(); + + // Bail out if we don't need to show anything else. + if (!stringName) { + gAccountSetupLogger.debug("Loading stopped"); + this.showHelperImage("step1"); + return; + } + + gAccountSetupLogger.debug(`Loading stopped: ${stringName}`); + + let notificationMessage = await document.l10n.formatValue(stringName); + + let notification = this.notificationBox.appendNotification( + "accountSetupSuccess", + { + label: notificationMessage, + priority: this.notificationBox.PRIORITY_INFO_HIGH, + }, + null + ); + notification.setAttribute("type", "success"); + + // Hide the close button to prevent dismissing the notification. + notification.removeAttribute("dismissable"); + + this.showHelperImage("step3"); + }, + + /** + * 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}`); + + this.showHelperImage("step4"); + + // Re-enable all form input fields. + for (let input of document.querySelectorAll("#form input")) { + input.removeAttribute("disabled"); + } + + // 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( + "accountSetupError", + { + label: notificationMessage, + priority: this.notificationBox.PRIORITY_WARNING_MEDIUM, + }, + null + ); + + // Hide the close button to prevent dismissing the notification. + notification.removeAttribute("dismissable"); + + this.ensureVisibleNotification(); + }, + + /** + * Hide all the helper images and show the requested one. + * + * @param {string} id - The string ID of the element to show. + */ + showHelperImage(id) { + // Hide all currently visible articles containing helper images in the + // second column. + for (let article of document.querySelectorAll( + ".second-column article:not([hidden])" + )) { + article.hidden = true; + } + + // Simply show the requested helper image if the user specified a reduced + // motion preference. + if (gReducedMotion) { + document.getElementById(id).hidden = false; + return; + } + + // Handle a nice cross fade between steps. + let stepToShow = document.getElementById(id); + // Add the class to let the revealing element start from a proper state. + stepToShow.classList.add("hide"); + stepToShow.hidden = false; + // Timeout to animate after the hidden attribute has been removed. + setTimeout(() => { + stepToShow.classList.remove("hide"); + }); + }, + + /** + * Always ensure the primary button is visible by scrolling the page until the + * button is above the fold. + */ + ensureVisibleButtons() { + // We use the #footDescription element to ensure the buttons are properly + // scrolled above the fold. + document.getElementById("footDescription").scrollIntoView({ + behavior: gReducedMotion ? "auto" : "smooth", + block: "end", + inline: "nearest", + }); + }, + + /** + * Always ensure the notification area is visible when a new notification is + * created. + */ + ensureVisibleNotification() { + document.getElementById("accountSetupNotifications").scrollIntoView({ + behavior: gReducedMotion ? "auto" : "smooth", + block: "start", + inline: "nearest", + }); + }, + + /** + * Populate the results config details area. + * + * @param {AccountConfig} config - The config to present to user. + */ + displayConfigResult(config) { + assert(config instanceof AccountConfig); + this._currentConfig = config; + let configFilledIn = this.getConcreteConfig(); + + // Filter out Protcols we don't currently support + let protocols = config.incomingAlternatives.filter(protocol => + ["imap", "pop3", "exchange"].includes(protocol.type) + ); + protocols.unshift(config.incoming); + protocols = protocols.reduce((found, nextEl) => { + if (!found.some(prevEl => prevEl.type == nextEl.type)) { + found.push(nextEl); + } + return found; + }, []); + + // Hide all the available options in order to start with a clean slate. + for (let row of document.querySelectorAll(".content-blocking-category")) { + row.classList.remove("selected"); + row.hidden = true; + } + + // Remove all previously generated protocol types. + for (let type of document.querySelectorAll(".config-type")) { + type.remove(); + } + + // Reveal all the matching protocols. + for (let protocol of protocols) { + let row = document.getElementById(`resultsOption-${protocol.type}`); + row.hidden = false; + // Attach the protocol to the radio input for later usage. + row.querySelector(`input[type="radio"]`).configIncoming = protocol; + } + + // Preselect the default protocol type. + let selected = document.getElementById( + `resultSelect-${config.incoming.type}` + ); + selected.closest(".content-blocking-category").classList.add("selected"); + selected.checked = true; + + // Update the results area title to match the protocols choice. + document.l10n.setAttributes( + document.getElementById("resultAreaTitle"), + "account-setup-results-area-title", + { + count: protocols.length, + } + ); + + // Ensure by default the "Done" button is enabled. + document.getElementById("createButton").disabled = false; + + // Thunderbird can't handle Exchange server independently, therefore we + // need to prompt the user with the installation of the Owl add-on. + if (config.incoming.type == "exchange") { + let addonsInstallRows = document.getElementById("resultAddonInstallRows"); + + // Remove any pre-existing child element. + while (addonsInstallRows.hasChildNodes()) { + addonsInstallRows.lastChild.remove(); + } + + let container = document.getElementById("resultExchangeHostname"); + _makeHostDisplayString(config.incoming, container); + document + .getElementById("incomingTitle-exchange") + .appendChild(_socketTypeSpan(config.incoming.socketType)); + + (async () => { + try { + for (let addon of config.addons) { + let installer = new AddonInstaller(addon); + addon.isInstalled = await installer.isInstalled(); + addon.isDisabled = await installer.isDisabled(); + } + + let addonInfoArea = document.getElementById("installAddonInfo"); + let installedAddon = config.addons.find(addon => addon.isInstalled); + + // The needed add-on is already installed, no need to show anything. + if (installedAddon) { + config.incoming.addonAccountType = + installedAddon.useType.addonAccountType; + addonInfoArea.hidden = true; + return; + } + // Disable "Done" until add-on is installed, or some other protocol + // is selected. + document.getElementById("createButton").disabled = true; + + addonInfoArea.hidden = false; + + document.l10n.setAttributes( + document.getElementById("resultAddonIntro"), + !config.incomingAlternatives.find(alt => + ["imap", "pop3"].includes(alt.type) + ) + ? "account-setup-addon-install-intro" + : "account-setup-addon-no-protocol" + ); + + for (let addon of config.addons) { + // Creates and addon install section. + // <div><img/><a></a><button></button></div> + let container = document.createElement("div"); + container.classList.add("addon-container"); + + let img = document.createElement("img"); + img.alt = ""; + img.classList.add("icon"); + if (addon.icon32) { + img.setAttribute("src", addon.icon32); + } + + let link = document.createElement("a"); + link.classList.add("link"); + link.setAttribute("href", addon.websiteURL); + link.textContent = addon.description; + + let button = document.createElement("button"); + document.l10n.setAttributes( + button, + "account-setup-addon-install-title" + ); + button.addEventListener("click", () => { + gAccountSetup.addonInstall(addon); + }); + if (addon.isDisabled) { + // If the add on is disabled by user, or due to incompatibility + // - disable install (it won't help, it's already installed) + // - link to the addons manager instead (so they can fix it) + button.disabled = true; + link.setAttribute("href", "about:addons"); + link.setAttribute("target", "_blank"); + + // Trigger an add-on update check. If an update is available, + // enable the install button to (re)install. + AddonManager.getAddonByID(addon.id).then(a => { + if (!a) { + return; + } + let listener = { + onUpdateAvailable(addon, install) { + button.disabled = false; + }, + onNoUpdateAvailable() {}, + }; + a.findUpdates( + listener, + AddonManager.UPDATE_WHEN_USER_REQUESTED + ); + }); + } + + container.appendChild(img); + container.appendChild(link); + container.appendChild(button); + + addonsInstallRows.appendChild(container); + } + } catch (e) { + this.showErrorNotification(e, true); + } + })(); + return; + } + + function _makeHostDisplayString(server, container) { + // Clean up any existing element. + while (container.hasChildNodes()) { + container.lastChild.remove(); + } + + let cert = container.parentNode.querySelector(".cert-status"); + if (cert != null) { + cert.remove(); + } + + let domain = server.hostname; + try { + domain = Services.eTLD.getBaseDomainFromHost(server.hostname); + } catch (ex) { + gAccountSetupLogger.warn(ex); + } + + let hostSpan = document.createElement("span"); + hostSpan.classList.add("host-without-domain"); + hostSpan.textContent = server.hostname.substr( + 0, + server.hostname.length - domain.length + ); + container.appendChild(hostSpan); + + let domainSpan = document.createElement("span"); + domainSpan.classList.add("domain"); + domainSpan.textContent = domain; + container.appendChild(domainSpan); + + if (!gAllStandardPorts.includes(server.port)) { + let portSpan = document.createElement("span"); + portSpan.classList.add("port"); + portSpan.textContent = `:${server.port}`; + container.appendChild(portSpan); + } + + if (server.badCert) { + container.parentNode + .querySelector(".cert-status") + .classList.add("insecure"); + } + } + + /** + * Helper method to create the span protocol element. + * + * @returns {HTMLElement} - The newly created span label. + */ + function _protocolTypeSpan() { + let span = document.createElement("span"); + span.classList.add("protocol-type", "config-type"); + return span; + } + + /** + * Helper method to create the span socket element. + * + * @param {nsMsgSocketType} socket - The value representing the server + * socket type. + * @returns {HTMLElement} - The newly created span label. + */ + function _socketTypeSpan(socket) { + let ssl = Sanitizer.translate(socket, { + 0: "no-encryption", + 2: "starttls", + 3: "ssl", + }); + let span = _protocolTypeSpan(); + document.l10n.setAttributes(span, `account-setup-result-${ssl}`); + span.classList.add("ssl"); + if (socket != 2 && socket != 3) { + // Not an SSL or STARTTLS socket. + span.classList.add("insecure"); + } + return span; + } + + let protocolType = config.incoming.type; + if (configFilledIn.incoming.hostname) { + _makeHostDisplayString( + configFilledIn.incoming, + document.getElementById(`incomingInfo-${protocolType}`) + ); + + let container = document.getElementById(`incomingTitle-${protocolType}`); + + // No need to show the protocol type if it's exchange, and the socket span + // is generated somewhere else specifically for exchange. + if (protocolType != "exchange") { + let span = _protocolTypeSpan(); + span.textContent = configFilledIn.incoming.type; + container.appendChild(span); + container.appendChild(_socketTypeSpan(config.incoming.socketType)); + } + } + + let outgoingInfo = document.getElementById(`outgoingInfo-${protocolType}`); + if (!config.outgoing.existingServerKey) { + if (configFilledIn.outgoing.hostname) { + _makeHostDisplayString(configFilledIn.outgoing, outgoingInfo); + } + let container = document.getElementById(`outgoingTitle-${protocolType}`); + // No need to show the protocol type if it's exchange, and the socket span + // is generated somewhere else specifically for exchange. + if (protocolType != "exchange") { + let span = _protocolTypeSpan(); + span.textContent = configFilledIn.outgoing.type; + container.appendChild(span); + container.appendChild(_socketTypeSpan(config.outgoing.socketType)); + } + } else { + let span = document.createElement("span"); + document.l10n.setAttributes( + span, + "account-setup-result-outgoing-existing" + ); + outgoingInfo.appendChild(span); + } + + let usernameInfo = document.getElementById( + `usernameInfo-${config.incoming.type}` + ); + if (configFilledIn.incoming.username == configFilledIn.outgoing.username) { + usernameInfo.textContent = configFilledIn.incoming.username; + } else { + document.l10n.setAttributes( + usernameInfo, + "account-setup-result-username-different", + { + incoming: configFilledIn.incoming.username, + outgoing: configFilledIn.outgoing.username, + } + ); + } + }, + + /** + * Handle the user switching between IMAP and POP3 settings using the + * radio buttons. + */ + onResultServerTypeChanged() { + let config = this._currentConfig; + // Add current server as best alternative to start of array. + config.incomingAlternatives.unshift(config.incoming); + + // Clear the visually selected radio container. + document + .querySelector(".content-blocking-category.selected") + .classList.remove("selected"); + + // Use selected server (stored as special property on the <input> node). + let selected = document.querySelector( + 'input[name="resultsServerType"]:checked' + ); + selected.closest(".content-blocking-category").classList.add("selected"); + config.incoming = selected.configIncoming; + + // Remove newly selected server from list of alternatives. + config.incomingAlternatives = config.incomingAlternatives.filter( + alt => alt != config.incoming + ); + this.displayConfigResult(config); + }, + + /** + * Install the addon + * Called when user clicks [Install] button. + * + * @param {AddonInfo} addon - @see AccountConfig.addons + */ + async addonInstall(addon) { + let addonInfoArea = document.getElementById("installAddonInfo"); + let createButton = document.getElementById("createButton"); + addonInfoArea.hidden = true; + createButton.disabled = true; + + this.clearNotifications(); + await this.startLoadingState("account-setup-installing-addon"); + + try { + let installer = (this._abortable = new AddonInstaller(addon)); + await installer.install(); + + this._abortable = null; + this.stopLoadingState("account-setup-success-addon"); + createButton.disabled = false; + + this._currentConfig.incoming.addonAccountType = + addon.useType.addonAccountType; + // Remove the note about having to install an add-on. + let rows = document.getElementById("resultAddonInstallRows"); + while (rows.lastChild) { + rows.lastChild.remove(); + } + } catch (e) { + console.error(e); + this.showErrorNotification(e, true); + addonInfoArea.hidden = false; + } + }, + + // ---------------- + // Manual Edit area + + /** + * Gets the values from the user in the manual edit area. Realname and + * password are not part of that area and still placeholders, but hostname and + * username are concrete and no placeholders anymore. + */ + getUserConfig() { + let config = this.getConcreteConfig() || new AccountConfig(); + config.source = AccountConfig.kSourceUser; + + // Incoming server + try { + let inHostnameField = document.getElementById("incomingHostname"); + config.incoming.hostname = Sanitizer.hostname(inHostnameField.value); + inHostnameField.value = config.incoming.hostname; + } catch (e) { + gAccountSetupLogger.warn(e); + } + + try { + config.incoming.port = Sanitizer.integerRange( + document.getElementById("incomingPort").value, + 1, + 65535 + ); + } catch (e) { + config.incoming.port = undefined; // incl. default "Auto" + } + + config.incoming.type = Sanitizer.translate( + document.getElementById("incomingProtocol").value, + { + 1: "imap", + 2: "pop3", + 3: "exchange", + 0: null, + } + ); + config.incoming.socketType = Sanitizer.integer( + document.getElementById("incomingSsl").value + ); + config.incoming.auth = Sanitizer.integer( + document.getElementById("incomingAuthMethod").value + ); + config.incoming.username = + document.getElementById("incomingUsername").value; + + // Outgoing server + + config.outgoing.username = + document.getElementById("outgoingUsername").value; + + // The user specified a custom SMTP server. + config.outgoing.existingServerKey = null; + config.outgoing.addThisServer = true; + config.outgoing.useGlobalPreferredServer = false; + + try { + let input = document.getElementById("outgoingHostname"); + config.outgoing.hostname = Sanitizer.hostname(input.value); + input.value = config.outgoing.hostname; + } catch (e) { + gAccountSetupLogger.warn(e); + } + + try { + config.outgoing.port = Sanitizer.integerRange( + document.getElementById("outgoingPort").value, + 1, + 65535 + ); + } catch (e) { + config.outgoing.port = undefined; // incl. default "Auto" + } + + config.outgoing.socketType = Sanitizer.integer( + document.getElementById("outgoingSsl").value + ); + config.outgoing.auth = Sanitizer.integer( + document.getElementById("outgoingAuthMethod").value + ); + + return config; + }, + + /** + * [Manual Config] button click handler. This turns the config details area + * into an editable form and makes the (Go) button appear. The edit button + * should only be available after the config probing is completely finished, + * replacing what was the (Stop) button. + */ + onManualEdit() { + if (this._abortable) { + this.onStop(); + } + this.editConfigDetails(); + this.showHelperImage("step3"); + }, + + /** + * Setting the config details form so it can be edited. We also disable + * (and hide) the create button during this time because we don't know what + * might have changed. The function called from the button that restarts + * the config check should be enabling the config button as needed. + */ + editConfigDetails() { + gAccountSetupLogger.debug("manual edit"); + + if (!this._currentConfig) { + this._currentConfig = new AccountConfig(); + this._currentConfig.incoming.type = "imap"; + this._currentConfig.incoming.username = "%EMAILADDRESS%"; + this._currentConfig.outgoing.username = "%EMAILADDRESS%"; + this._currentConfig.incoming.hostname = ".%EMAILDOMAIN%"; + this._currentConfig.outgoing.hostname = ".%EMAILDOMAIN%"; + } + // Although we go manual, and we need to display the concrete username, + // however the realname and password is not part of manual config and + // must stay a placeholder in _currentConfig. @see getUserConfig() + + this._fillManualEditFields(this.getConcreteConfig()); + + // _fillManualEditFields() indirectly calls validateManualEditComplete(), + // but it's important to not forget it in case the code is rewritten, + // so calling it explicitly again. Doesn't do harm, speed is irrelevant. + this.validateManualEditComplete(); + }, + + /** + * Fills the manual edit textfields with the provided config. + * + * @param {AccountConfig} config - The config to present to the user. + */ + _fillManualEditFields(config) { + assert(config instanceof AccountConfig); + + let isExchange = config.incoming.type == "exchange"; + + // Incoming server. + document.getElementById("incomingProtocolExchange").hidden = !isExchange; + document.getElementById("incomingProtocol").value = Sanitizer.translate( + config.incoming.type, + { imap: 1, pop3: 2, exchange: 3 }, + 1 + ); + document.getElementById("incomingHostname").value = + config.incoming.hostname; + document.getElementById("incomingSsl").value = Sanitizer.enum( + config.incoming.socketType, + [0, 1, 2, 3], + 0 + ); + document.getElementById("incomingAuthMethod").value = Sanitizer.enum( + config.incoming.auth, + [0, 3, 4, 5, 6, 10], + 0 + ); + document.getElementById("incomingUsername").value = + config.incoming.username; + + // If a port number was specified other than "Auto" + if (config.incoming.port) { + document.getElementById("incomingPort").value = config.incoming.port; + } else { + this.adjustIncomingPortToSSLAndProtocol(config); + } + + // Outgoing server. + + document.getElementById("outgoingHostname").value = + config.outgoing.hostname; + document.getElementById("outgoingUsername").value = + config.outgoing.username; + + // While sameInOutUsernames is true we synchronize values of incoming + // and outgoing username. + this.sameInOutUsernames = true; + document.getElementById("outgoingSsl").value = Sanitizer.enum( + config.outgoing.socketType, + [0, 1, 2, 3], + 0 + ); + document.getElementById("outgoingAuthMethod").value = Sanitizer.enum( + config.outgoing.auth, + [0, 1, 3, 4, 5, 6, 10], + 0 + ); + + // If a port number was specified other than "Auto" + if (config.outgoing.port) { + document.getElementById("outgoingPort").value = config.outgoing.port; + } else { + this.adjustOutgoingPortToSSLAndProtocol(config); + } + + this.adjustOAuth2Visibility(config); + }, + + /** + * Make OAuth2 visible as an authentication method when a hostname that + * OAuth2 can be used with is entered. + * + * @param {AccountConfig} config - The account configuration. + */ + async adjustOAuth2Visibility(config) { + // If the incoming server hostname supports OAuth2, enable it. + let iDetails = OAuth2Providers.getHostnameDetails(config.incoming.hostname); + document.getElementById("in-authMethod-oauth2").hidden = !iDetails; + if (iDetails) { + gAccountSetupLogger.debug( + `OAuth2 details for incoming server ${config.incoming.hostname} is ${iDetails}` + ); + config.incoming.oauthSettings = {}; + [ + config.incoming.oauthSettings.issuer, + config.incoming.oauthSettings.scope, + ] = iDetails; + this._currentConfig.incoming.oauthSettings = + config.incoming.oauthSettings; + } + + // If the smtp hostname supports OAuth2, enable it. + let oDetails = OAuth2Providers.getHostnameDetails(config.outgoing.hostname); + document.getElementById("out-authMethod-oauth2").hidden = !oDetails; + if (oDetails) { + gAccountSetupLogger.debug( + `OAuth2 details for outgoing server ${config.outgoing.hostname} is ${oDetails}` + ); + config.outgoing.oauthSettings = {}; + [ + config.outgoing.oauthSettings.issuer, + config.outgoing.oauthSettings.scope, + ] = oDetails; + this._currentConfig.outgoing.oauthSettings = + config.outgoing.oauthSettings; + } + }, + + /** + * Automatically fill port field in manual edit, unless the user entered a + * non-standard port. + * + * @param {AccountConfig} config - The account configuration. + */ + async adjustIncomingPortToSSLAndProtocol(config) { + let incoming = config.incoming; + + // Bail out if a port number is already defined and it's not part of the + // known ports array. + if (!gAllStandardPorts.includes(incoming.port)) { + return; + } + + let input = document.getElementById("incomingPort"); + + switch (incoming.type) { + case "imap": + input.value = incoming.socketType == Ci.nsMsgSocketType.SSL ? 993 : 143; + break; + + case "pop3": + input.value = incoming.socketType == Ci.nsMsgSocketType.SSL ? 995 : 110; + break; + + case "exchange": + input.value = 443; + break; + } + }, + + /** + * Automatically fill port field in manual edit, unless the user entered a + * non-standard port. + * + * @param {AccountConfig} config - The account configuration. + */ + async adjustOutgoingPortToSSLAndProtocol(config) { + let outgoing = config.outgoing; + + // Bail out if a port number is already defined and it's not part of the + // known ports array. + if (!gAllStandardPorts.includes(outgoing.port)) { + return; + } + + // Implicit TLS for SMTP is on port 465. + if (outgoing.socketType == Ci.nsMsgSocketType.SSL) { + document.getElementById("outgoingPort").value = 465; + return; + } + + // Implicit TLS for SMTP is on port 465. STARTTLS won't work there. + if ( + outgoing.port == 465 && + outgoing.socketType == Ci.nsMsgSocketType.alwaysSTARTTLS + ) { + document.getElementById("outgoingPort").value = 587; + } + }, + + /** + * If the user changed the port manually, adjust the SSL value, + * (only) if the new port is impossible with the old SSL value. + * + * @param config {AccountConfig} + */ + adjustIncomingSSLToPort(config) { + let incoming = config.incoming; + if (!gAllStandardPorts.includes(incoming.port)) { + return; + } + + if (incoming.type == "imap") { + // Implicit TLS for IMAP is on port 993. + if ( + incoming.port == 993 && + incoming.socketType != Ci.nsMsgSocketType.SSL + ) { + document.getElementById("incomingSsl").value = Ci.nsMsgSocketType.SSL; + return; + } + if ( + incoming.port == 143 && + incoming.socketType == Ci.nsMsgSocketType.SSL + ) { + document.getElementById("incomingSsl").value = + Ci.nsMsgSocketType.alwaysSTARTTLS; + return; + } + } + + if (incoming.type == "pop3") { + // Implicit TLS for POP3 is on port 995. + if ( + incoming.port == 995 && + incoming.socketType != Ci.nsMsgSocketType.SSL + ) { + document.getElementById("incomingSsl").value = Ci.nsMsgSocketType.SSL; + return; + } + if ( + incoming.port == 110 && + incoming.socketType == Ci.nsMsgSocketType.SSL + ) { + document.getElementById("incomingSsl").value = + Ci.nsMsgSocketType.alwaysSTARTTLS; + } + } + }, + + /** + * @see adjustIncomingSSLToPort() + */ + adjustOutgoingSSLToPort(config) { + let outgoing = config.outgoing; + if (!gAllStandardPorts.includes(outgoing.port)) { + return; + } + + // Implicit TLS for SMTP is on port 465. + if (outgoing.port == 465 && outgoing.socketType != Ci.nsMsgSocketType.SSL) { + document.getElementById("outgoingSsl").value = Ci.nsMsgSocketType.SSL; + return; + } + + // Port 587 and port 25 are for plain or STARTTLS. Not for Implicit TLS. + if ( + (outgoing.port == 587 || outgoing.port == 25) && + outgoing.socketType == Ci.nsMsgSocketType.SSL + ) { + document.getElementById("outgoingSsl").value = + Ci.nsMsgSocketType.alwaysSTARTTLS; + } + }, + + onChangedProtocolIncoming() { + let config = this.getUserConfig(); + this.adjustIncomingPortToSSLAndProtocol(config); + this.onChangedManualEdit(); + }, + + onChangedPortIncoming() { + gAccountSetupLogger.debug( + "incoming port changed: " + document.getElementById("incomingPort").value + ); + this.adjustIncomingSSLToPort(this.getUserConfig()); + this.onChangedManualEdit(); + }, + + onChangedPortOutgoing() { + gAccountSetupLogger.debug( + "outgoing port changed: " + document.getElementById("outgoingPort").value + ); + this.adjustOutgoingSSLToPort(this.getUserConfig()); + this.onChangedManualEdit(); + }, + + onChangedSSLIncoming() { + this.adjustIncomingPortToSSLAndProtocol(this.getUserConfig()); + this.onChangedManualEdit(); + }, + + onChangedSSLOutgoing() { + this.adjustOutgoingPortToSSLAndProtocol(this.getUserConfig()); + this.onChangedManualEdit(); + }, + + onChangedInAuth() { + this.onChangedManualEdit(); + }, + + onChangedOutAuth(event) { + // Disable the outgoing username field if the "No Authentication" option is + // selected. + document.getElementById("outgoingUsername").disabled = + event.target.selectedOptions[0].id == "outNoAuth"; + this.onChangedManualEdit(); + }, + + onInputInUsername() { + if (this.sameInOutUsernames) { + document.getElementById("outgoingUsername").value = + document.getElementById("incomingUsername").value; + } + this.onChangedManualEdit(); + }, + + onInputOutUsername() { + this.sameInOutUsernames = false; + this.onChangedManualEdit(); + }, + + onChangeHostname() { + this.adjustOAuth2Visibility(this.getUserConfig()); + this.onChangedManualEdit(); + }, + + /** + * A value in the manual configuration area was changed. + */ + onChangedManualEdit() { + // If there's a current operation in progress and is abortable. + if (this._abortable) { + this.onStop(); + } + this.validateManualEditComplete(); + }, + + /** + * The user interacted with an input field in the manual configuration area + * therefore we need to clear previous notifications and disable the "Done" + * button as the current config is not valid until we run again the + * validateManualEditComplete() method, which happens on input blur. + */ + manualConfigChanged() { + this.clearNotifications(); + document.getElementById("createButton").disabled = true; + }, + + /** + * This enables the buttons which allow the user to proceed + * once he has entered enough information. + * + * We can easily and fairly surely autodetect everything apart from the + * hostname (and username). So, once the user has entered + * proper hostnames, change to "manual-edit-have-hostname" mode + * which allows to press [Re-test], which starts the detection + * of the other values. + * Once the user has entered (or we detected) all values, he may + * do [Create Account] (tests login and if successful creates the account) + * or [Advanced Setup] (goes to Account Manager). Esp. in the latter case, + * we will not second-guess his setup and just to as told, so here we make + * sure that he at least entered all values. + */ + validateManualEditComplete() { + // getUserConfig() is expensive, but still OK, not a problem. + let manualConfig = this.getUserConfig(); + this._currentConfig = manualConfig; + + if (manualConfig.isComplete()) { + this.switchToMode("manual-edit-complete"); + return; + } + + if (!!manualConfig.incoming.hostname && !!manualConfig.outgoing.hostname) { + this.switchToMode("manual-edit-have-hostname"); + return; + } + + this.switchToMode("manual-edit"); + }, + + /** + * [Advanced Setup...] button click handler + * Only active in manual edit mode, and goes straight into + * Account Settings (pref UI) dialog. Requires a backend account, + * which requires proper hostname, port and protocol. + */ + async onAdvancedSetup() { + assert(this._currentConfig instanceof AccountConfig); + let configFilledIn = this.getConcreteConfig(); + + if (CreateInBackend.checkIncomingServerAlreadyExists(configFilledIn)) { + let [title, description] = await document.l10n.formatValues([ + "account-setup-creation-error-title", + "account-setup-error-server-exists", + ]); + Services.prompt.alert(null, title, description); + return; + } + + let [title, description] = await document.l10n.formatValues([ + "account-setup-confirm-advanced-title", + "account-setup-confirm-advanced-description", + ]); + + if (!Services.prompt.confirm(null, title, description)) { + return; + } + + gAccountSetupLogger.debug("creating account in backend"); + let newAccount = CreateInBackend.createAccountInBackend(configFilledIn); + + window.close(); + gMainWindow.postMessage("account-created-in-backend", "*"); + MsgAccountManager("am-server.xhtml", newAccount.incomingServer); + }, + + /** + * [Re-test] button click handler. + * Restarts the config guessing process after a person editing the server + * fields. + * It's called "half-manual", because we take the user-entered values + * as given and will not second-guess them, to respect the user wishes. + * (Yes, Sir! Will do as told!) + * The values that the user left empty or on "Auto" will be guessed/probed + * here. We will also check that the user-provided values work. + */ + async testManualConfig() { + this.clearNotifications(); + await this.startLoadingState( + "account-setup-looking-up-settings-half-manual" + ); + + let newConfig = this.getUserConfig(); + gAccountSetupLogger.debug("manual config to test:\n" + newConfig); + + this.switchToMode("manual-edit-testing"); + // if (this._userPickedOutgoingServer) TODO + let self = this; + this._abortable = GuessConfig.guessConfig( + this._domain, + function (type, hostname, port, ssl, done, config) { + // Progress. + gAccountSetupLogger.debug( + `progress callback host: ${hostname}, port: ${port}, type: ${type}` + ); + }, + function (config) { + // Success. + self._abortable = null; + self._fillManualEditFields(config); + self.stopLoadingState("account-setup-success-half-manual"); + self.validateManualEditComplete(); + }, + function (e, config) { + // guessConfig failed. + if (e instanceof CancelledException) { + return; + } + self._abortable = null; + gAccountSetupLogger.warn(`guessConfig failed: ${e}`); + self.showErrorNotification("account-setup-find-settings-failed"); + self.switchToMode("manual-edit-have-hostname"); + }, + newConfig, + newConfig.outgoing.existingServerKey ? "incoming" : "both" + ); + }, + + // ------------------- + // UI helper functions + + _prefillConfig(initialConfig) { + let emailsplit = this._email.split("@"); + assert(emailsplit.length > 1); + let emaillocal = Sanitizer.nonemptystring(emailsplit[0]); + initialConfig.incoming.username = emaillocal; + initialConfig.outgoing.username = emaillocal; + return initialConfig; + }, + + clearError(which) { + document.getElementById(`${which}Warning`).hidden = true; + document.getElementById(`${which}Info`).hidden = false; + }, + + setError(which, msg_name) { + try { + document.getElementById(`${which}Info`).hidden = true; + document.getElementById(`${which}Warning`).hidden = false; + } catch (ex) { + alertPrompt("Missing error string", msg_name); + } + }, + + onFormSubmit(event) { + // Prevent the actual form submission. + event.preventDefault(); + + // Select the only primary button that is visible and enabled. + let currentButton = document.querySelector( + ".buttons-container-last button.primary:not([disabled],[hidden])" + ); + if (currentButton) { + currentButton.click(); + } + }, + + // ------------------------------- + // Finish & dialog close functions + + onCancel() { + // Some tests might close the account setup before it finishes loading, + // therefore the gMainWindow might still be null. If that's the case, do an + // early return since we don't need to run any condition. + if (!gMainWindow) { + window.close(); + return; + } + + // Ask for confirmation if the user never set Thunderbrid to be used without + // an email account, and no account has been configured. + if ( + !Services.prefs.getBoolPref("app.use_without_mail_account", false) && + !MailServices.accounts.accounts.length + ) { + // Abort any possible process before showing the confirmation dialog. + this.checkIfAbortable(); + this.confirmExitDialog(); + return; + } + + window.close(); + }, + + /** + * Ask for confirmation when the account setup is dismissed and the user + * doesn't have any configured account. + */ + confirmExitDialog() { + let dialog = document.getElementById("confirmExitDialog"); + + document.getElementById("exitDialogConfirmButton").onclick = () => { + // Update the pref only if the checkbox was checked since it's FALSE by + // default. We won't expose this checkbox in the UI anymore afterward. + if (document.getElementById("useWithoutAccount").checked) { + Services.prefs.setBoolPref("app.use_without_mail_account", true); + } + + dialog.close(); + window.close(); + }; + + document.getElementById("exitDialogCancelButton").onclick = () => { + dialog.close(); + }; + + dialog.showModal(); + }, + + /** + * Disable the exit dialog button if the user checks the "Use without an email + * account" checkbox. + * + * @param {DOMEvent} event - The checkbox change event. + */ + toggleExitDialogButton(event) { + document.getElementById("exitDialogCancelButton").disabled = + event.target.checked; + }, + + checkIfAbortable() { + if (this._abortable) { + this._abortable.cancel(new UserCancelledException()); + } + }, + + onUnload() { + gMainWindow.document + .getElementById("tabmail") + .unregisterTabMonitor(this.tabMonitor); + this.checkIfAbortable(); + gAccountSetupLogger.debug("Shutting down email config dialog"); + }, + + async onCreate() { + gAccountSetupLogger.debug("Create button clicked"); + + let configFilledIn = this.getConcreteConfig(); + let self = this; + // If the dialog is not needed, it will go straight to OK callback + gSecurityWarningDialog.open( + this._currentConfig, + configFilledIn, + true, + async function () { + // on OK + await self.validateAndFinish(configFilledIn).catch(async ex => { + let errorMessage = await document.l10n.formatValue( + "account-setup-creation-error-title" + ); + gAccountSetupLogger.error(errorMessage + ". " + ex); + + self.clearNotifications(); + let notification = self.notificationBox.appendNotification( + "accountSetupError", + { + label: errorMessage, + priority: self.notificationBox.PRIORITY_CRITICAL_HIGH, + }, + null + ); + + // Hide the close button to prevent dismissing the notification. + notification.removeAttribute("dismissable"); + }); + }, + function () { + // on cancel, do nothing + } + ); + }, + + // called by onCreate() + async validateAndFinish(configFilled) { + let configFilledIn = configFilled || this.getConcreteConfig(); + if ( + configFilledIn.incoming.type == "exchange" && + "addonAccountType" in configFilledIn.incoming + ) { + configFilledIn.incoming.type = configFilledIn.incoming.addonAccountType; + } + + if (CreateInBackend.checkIncomingServerAlreadyExists(configFilledIn)) { + let [title, description] = await document.l10n.formatValues([ + "account-setup-creation-error-title", + "account-setup-error-server-exists", + ]); + Services.prompt.alert(null, title, description); + return; + } + + if (configFilledIn.outgoing.addThisServer) { + let existingServer = + CreateInBackend.checkOutgoingServerAlreadyExists(configFilledIn); + if (existingServer) { + configFilledIn.outgoing.addThisServer = false; + configFilledIn.outgoing.existingServerKey = existingServer.key; + } + } + + let createButton = document.getElementById("createButton"); + let reTestButton = document.getElementById("reTestButton"); + createButton.disabled = true; + reTestButton.disabled = true; + + this.clearNotifications(); + this.startLoadingState("account-setup-checking-password"); + let telemetryKey = + this._currentConfig.source == AccountConfig.kSourceXML || + this._currentConfig.source == AccountConfig.kSourceExchange + ? this._currentConfig.subSource + : this._currentConfig.source; + + let self = this; + let verifier = new ConfigVerifier(this._msgWindow); + window.addEventListener("unload", event => { + verifier.cleanup(); + }); + verifier + .verifyConfig( + configFilledIn, + // guess login config? + configFilledIn.source != AccountConfig.kSourceXML + // TODO Instead, the following line would be correct, but I cannot use it, + // because some other code doesn't adhere to the expectations/specs. + // Find out what it was and fix it. + // concreteConfig.source == AccountConfig.kSourceGuess, + ) + .then(successfulConfig => { + // success + self.stopLoadingState( + successfulConfig.incoming.password + ? "account-setup-success-password" + : null + ); + + // The auth might have changed, so we should update the current config. + self._currentConfig.incoming.auth = successfulConfig.incoming.auth; + self._currentConfig.outgoing.auth = successfulConfig.outgoing.auth; + self._currentConfig.incoming.username = + successfulConfig.incoming.username; + self._currentConfig.outgoing.username = + successfulConfig.outgoing.username; + + // We loaded dynamic client registration, fill this data back in to the + // config set. + if (successfulConfig.incoming.oauthSettings) { + self._currentConfig.incoming.oauthSettings = + successfulConfig.incoming.oauthSettings; + } + if (successfulConfig.outgoing.oauthSettings) { + self._currentConfig.outgoing.oauthSettings = + successfulConfig.outgoing.oauthSettings; + } + self.finish(configFilledIn); + + Services.telemetry.keyedScalarAdd( + "tb.account.successful_email_account_setup", + telemetryKey, + 1 + ); + }) + .catch(e => { + // failed + // Could be a wrong password, but there are 1000 other + // reasons why this failed. Only the backend knows. + // If we got no message, then something other than VerifyLogon failed. + + // For an Exchange server, some known configurations can + // be disabled (per user or domain or server). + // Warn the user if the open protocol we tried didn't work. + if ( + ["imap", "pop3"].includes(configFilledIn.incoming.type) && + configFilledIn.incomingAlternatives.some(i => i.type == "exchange") + ) { + self.showErrorNotification( + "account-setup-exchange-config-unverifiable" + ); + } else { + let msg = e.message || e.toString(); + self.showErrorNotification(msg, true); + } + + // give user something to proceed after fixing + createButton.disabled = false; + // hidden in non-manual mode, so it's fine to enable + reTestButton.disabled = false; + + Services.telemetry.keyedScalarAdd( + "tb.account.failed_email_account_setup", + telemetryKey, + 1 + ); + }); + }, + + /** + * @param {AccountConfig} concreteConfig - The config to use. + */ + finish(concreteConfig) { + gAccountSetupLogger.debug("creating account in backend"); + let newAccount = CreateInBackend.createAccountInBackend(concreteConfig); + + // Trigger the first login to download the folder structure and messages. + newAccount.incomingServer.getNewMessages( + newAccount.incomingServer.rootFolder, + this._msgWindow, + null + ); + + if (this._okCallback) { + this._okCallback(); + } + + this.showSuccessView(newAccount); + }, + + /** + * Toggle the visibility of the list of available services to configure. + */ + toggleSetupContainer(event) { + let container = event.target.closest(".linked-services-section"); + container.classList.toggle("opened"); + container + .querySelector(".linked-services-container") + .toggleAttribute("hidden"); + }, + + /** + * Update the account setup tab to show a successful final view with quick + * links and suggested next steps. + * + * @param {nsIMsgAccount} account - The newly created account. + */ + async showSuccessView(account) { + gAccountSetupLogger.debug("Account creation successful"); + + // Populate the account recap info. + document.getElementById("newAccountName").textContent = this._realname; + document.getElementById("newAccountEmail").textContent = this._email; + document.getElementById("newAccountProtocol").textContent = + account.incomingServer.type; + + // Store the host domain that will be used to look for CardDAV and CalDAV + // services. + this._hostname = this._email.split("@")[1]; + + // Set up event listeners for the quick links. + document.getElementById("settingsButton").addEventListener( + "click", + () => { + MsgAccountManager(null, account.incomingServer); + }, + { once: true } + ); + + // Hide the e2ee button if the current server doesn't support it. + let hasEncryption = + account.incomingServer.type != "rss" && + account.incomingServer.type != "nntp" && + account.incomingServer.protocolInfo?.canGetMessages; + document.getElementById("encryptionButton").hidden = !hasEncryption; + if (hasEncryption) { + document + .getElementById("encryptionButton") + .addEventListener("click", () => { + MsgAccountManager("am-e2e.xhtml", account.incomingServer); + }); + } + + document.getElementById("signatureButton").addEventListener("click", () => { + MsgAccountManager(null, account.incomingServer); + }); + + // Finally, show the success view. + this.switchToMode("success"); + + // Initialize the fetching of possible linked services like address books + // or calendars. + gAccountSetupLogger.debug("Fetching linked address books and calendars"); + + let notification = this.syncingBox.appendNotification( + "accountSetupLoading", + { + label: await document.l10n.formatValue( + "account-setup-looking-up-address-books" + ), + priority: this.syncingBox.PRIORITY_INFO_LOW, + }, + null + ); + notification.setAttribute("align", "center"); + + // Hide the close button to prevent dismissing the notification. + notification.removeAttribute("dismissable"); + + // Detect linked address books. + await this.fetchAddressBooks(); + + // Update the notification and start detecting linked calendars. + document.l10n.setAttributes( + notification.messageText, + "account-setup-looking-up-calendars" + ); + await this.fetchCalendars(); + + // Update the connected services description if we have at least one address + // book or one calendar we can connect to. + document.l10n.setAttributes( + document.getElementById("linkedServicesDescription"), + !this.addressBooks.length && !this.calendars.size + ? "account-setup-no-linked-description" + : "account-setup-linked-services-description" + ); + + // Clear the loading notification. + this.syncingBox.removeAllNotifications(); + this.showHelperImage("step5"); + }, + + /** + * Fetch any available CardDAV address books. + */ + async fetchAddressBooks() { + this.addressBooks = []; + try { + this.addressBooks = await CardDAVUtils.detectAddressBooks( + this._email, + this._password, + `https://${this._hostname}`, + false + ); + } catch (ex) { + gAccountSetupLogger.error(ex); + } + + let hideAddressBookUI = !this.addressBooks.length; + document.getElementById("linkedAddressBooks").hidden = hideAddressBookUI; + + // Clear the UI from any previous list. + let abList = document.querySelector( + "#addressBooksSetup .linked-services-list" + ); + while (abList.hasChildNodes()) { + abList.lastChild.remove(); + } + + // Interrupt if we don't have anything to show. + if (hideAddressBookUI) { + return; + } + + document.l10n.setAttributes( + document.getElementById("addressBooksCountDescription"), + "account-setup-found-address-books-description", + { count: this.addressBooks.length } + ); + + // Collect existing carddav address books to compare with the list of + // recently fetched ones. + let existing = MailServices.ab.directories.map(d => + d.getStringValue("carddav.url", "") + ); + + // Populate the list of available address books. + for (let book of this.addressBooks) { + let provider = document.createElement("span"); + provider.classList.add("protocol-type"); + provider.textContent = "CardDAV"; + + let name = document.createElement("span"); + name.classList.add("list-item-name"); + name.textContent = book.name; + + let button = document.createElement("button"); + button.setAttribute("type", "button"); + + if (existing.includes(book.url.href)) { + // This address book aready exists for some reason, so disable the + // button and mark it as existing. + button.classList.add("existing", "small-button"); + document.l10n.setAttributes( + button, + "account-setup-existing-address-book" + ); + button.disabled = true; + } else { + button.classList.add("small-button"); + document.l10n.setAttributes(button, "account-setup-connect-link"); + button.addEventListener("click", () => { + this._setupAddressBook(button, book); + }); + } + + let row = document.createElement("li"); + row.appendChild(provider); + row.appendChild(name); + row.appendChild(button); + abList.appendChild(row); + } + + // Show a "connect all" button if we have more than one address book. + document.getElementById("addressBooksSetupAll").hidden = + this.addressBooks.length <= 1; + }, + + /** + * Connect to the selected address book. + * + * @param {HTMLElement} button - The clicked button in the list. + * @param {foundBook} book - The address book to configure. + */ + _setupAddressBook(button, book) { + book.create(); + + // Update the button to reflect the creation of the new address book. + button.classList.add("existing"); + document.l10n.setAttributes(button, "account-setup-existing-address-book"); + button.disabled = true; + + // Check if we have any address book left to set up and hide the + // "Connect all" button if not. + document.getElementById("addressBooksSetupAll").hidden = + !document.querySelectorAll( + "#addressBooksSetup .linked-services-list button:not(.existing)" + ).length; + }, + + /** + * Loop through all available address books found and click the connect + * button to trigger the method attached to the onclick listener. + */ + setupAllAddressBooks() { + for (let button of document.querySelectorAll( + "#addressBooksSetup .linked-services-list button" + )) { + button.click(); + } + }, + + /** + * Fetch any available CalDAV calendars. + */ + async fetchCalendars() { + this.calendars = {}; + try { + this.calendars = await cal.provider.detection.detect( + this._email, + this._password, + `https://${this._hostname}`, + document.getElementById("rememberPassword").checked, + [], + {} + ); + } catch (ex) { + gAccountSetupLogger.error(ex); + } + + let hideCalendarUI = !this.calendars.size; + document.getElementById("linkedCalendars").hidden = hideCalendarUI; + + // Clear the UI from any previous list. + let calList = document.querySelector( + "#calendarsSetup .linked-services-list" + ); + while (calList.hasChildNodes()) { + calList.lastChild.remove(); + } + + // Interrupt if we don't have anything to show. + if (hideCalendarUI) { + return; + } + + // Collect existing calendars to compare with the list of recently fetched + // ones. + let existing = new Set( + cal.manager.getCalendars({}).map(calendar => calendar.uri.spec) + ); + + let calendarsCount = 0; + + // Populate the list of available calendars. + for (let [provider, calendars] of this.calendars.entries()) { + for (let calendar of calendars) { + let cal_provider = document.createElement("span"); + cal_provider.classList.add("protocol-type"); + cal_provider.textContent = provider.shortName; + + let cal_name = document.createElement("span"); + cal_name.classList.add("list-item-name"); + cal_name.textContent = calendar.name; + + let button = document.createElement("button"); + button.setAttribute("type", "button"); + + if (existing.has(calendar.uri.spec)) { + // This calendar aready exists for some reason, so disable the button + // and mark it as existing. + button.classList.add("existing", "small-button"); + document.l10n.setAttributes( + button, + "account-setup-existing-calendar" + ); + button.disabled = true; + } else { + button.classList.add("small-button"); + document.l10n.setAttributes(button, "account-setup-connect-link"); + button.addEventListener("click", () => { + // If the button has a specific data attribute it means we want to + // set up the calendar directly without opening the dialog. + if (button.hasAttribute("data-setup-calendar")) { + this._setupCalendar(button, calendar); + return; + } + + this._showCalendarDialog(button, calendar); + }); + } + + let row = document.createElement("li"); + row.appendChild(cal_provider); + row.appendChild(cal_name); + row.appendChild(button); + calList.appendChild(row); + + calendarsCount++; + } + } + + document.l10n.setAttributes( + document.getElementById("calendarsCountDescription"), + "account-setup-found-calendars-description", + { count: calendarsCount } + ); + + // Show a "connect all" button if we have more than one calendar. + document.getElementById("calendarsSetupAll").hidden = calendarsCount <= 1; + }, + + /** + * Show the dialog to connect the selected calendar. This native HTML dialog + * is a streamlined version of the calendar-properties-dialog.xhtml. The two + * dialogs should kept in sync if a property of the calendar changes that + * requires updating any field. + * + * @param {HTMLElement} button - The clicked button in the list. + * @param {calICalendar} calendar - The calendar to configure. + */ + _showCalendarDialog(button, calendar) { + let dialog = document.getElementById("calendarDialog"); + + // Update the calendar info in the dialog. + let nameInput = document.getElementById("calendarName"); + nameInput.value = calendar.name; + + // Some servers provide colors as an 8-character hex string, which the color + // picker can't handle. Strip the alpha component. + let color = calendar.getProperty("color"); + let alpha = color?.match(/^(#[0-9A-Fa-f]{6})[0-9A-Fa-f]{2}$/); + if (alpha) { + calendar.setProperty("color", alpha[1]); + color = alpha[1]; + } + let colorInput = document.getElementById("calendarColor"); + colorInput.value = color || "#A8C2E1"; + + let readOnlyCheckbox = document.getElementById("calendarReadOnly"); + readOnlyCheckbox.checked = calendar.readOnly; + + // Hide the "Show reminders" checkbox if the calendar doesn't support it. + document.getElementById("calendarShowRemindersRow").hidden = + calendar.getProperty("capabilities.alarms.popup.supported") === false; + let remindersCheckbox = document.getElementById("calendarShowReminders"); + remindersCheckbox.checked = !calendar.getProperty("suppressAlarms"); + + // Hide the "Offline support" if the calendar doesn't support it. + let offlineCheckbox = document.getElementById("calendarOfflineSupport"); + let canCache = calendar.getProperty("cache.supported") !== false; + let alwaysCache = calendar.getProperty("cache.always"); + if (!canCache || alwaysCache) { + offlineCheckbox.hidden = true; + offlineCheckbox.disabled = true; + } + offlineCheckbox.checked = + alwaysCache || (canCache && calendar.getProperty("cache.enabled")); + + // Set up the "Refresh calendar" menulist. + let calendarRefresh = document.getElementById("calendarRefresh"); + calendarRefresh.disabled = !calendar.canRefresh; + calendarRefresh.value = calendar.getProperty("refreshInterval") || 30; + + // Set up the dialog's action buttons. + document.getElementById("calendarDialogConfirmButton").onclick = () => { + // Update the attributes of the calendar in case the user changed some + // values. + calendar.name = nameInput.value; + calendar.setProperty("color", colorInput.value); + if (calendar.canRefresh) { + calendar.setProperty("refreshInterval", calendarRefresh.value); + } + + calendar.readOnly = readOnlyCheckbox.checked; + calendar.setProperty("suppressAlarms", !remindersCheckbox.checked); + if (!alwaysCache) { + calendar.setProperty("cache.enabled", offlineCheckbox.checked); + } + + this._setupCalendar(button, calendar); + dialog.close(); + }; + + document.getElementById("calendarDialogCancelButton").onclick = () => { + dialog.close(); + }; + + dialog.showModal(); + }, + + /** + * Connect to the selected calendar. + * + * @param {HTMLElement} button - The clicked button in the list. + * @param {calICalendar} calendar - The calendar to configure. + */ + _setupCalendar(button, calendar) { + cal.manager.registerCalendar(calendar); + + // Update the button to reflect the creation of the new calendar. + button.classList.add("existing"); + document.l10n.setAttributes(button, "account-setup-existing-calendar"); + button.disabled = true; + + // Check if we have any calendar left to set up and hide the "Connect all" + // button if not. + document.getElementById("calendarsSetupAll").hidden = + !document.querySelectorAll( + "#calendarsSetup .linked-services-list button:not(.existing)" + ).length; + }, + + /** + * Loop through all available calendars found and click the connect + * button to trigger the method attached to the onclick listener. + */ + setupAllCalendars() { + for (let button of document.querySelectorAll( + "#calendarsSetup .linked-services-list button:not(.existing)" + )) { + // Set the attribute to skip the opening of the properties dialog. + button.setAttribute("data-setup-calendar", true); + button.click(); + } + }, + + /** + * Called from the very final view of the account setup, when the user decides + * to close the wizard. + */ + onFinish() { + // Send the message to the mail tab in case the UI didn't load during the + // previous setup callback. + gMainWindow.postMessage("account-setup-closed", "*"); + // Close this tab. + window.close(); + }, +}; + +function serverMatches(a, b) { + return ( + a.type == b.type && + a.hostname == b.hostname && + a.port == b.port && + a.socketType == b.socketType && + a.auth == b.auth + ); +} + +/** + * Warning dialog, warning user about lack of, or inappropriate, encryption. + */ +var gSecurityWarningDialog = { + /** + * {Array of {(incoming or outgoing) server part of {AccountConfig}} + * A list of the servers for which we already showed this dialog and the + * user approved the configs. For those, we won't show the warning again. + * (Make sure to store a copy in case the underlying object is changed.) + */ + _acknowledged: [], + + _inSecurityBad: 0x0001, + _inCertBad: 0x0010, + _outSecurityBad: 0x0100, + _outCertBad: 0x1000, + + /** + * Checks whether we need to warn about this config. + * + * We (currently) warn if + * - the mail travels unsecured (no SSL/STARTTLS) + * - (We don't warn about unencrypted passwords specifically, + * because they'd be encrypted with SSL and without SSL, we'd + * warn anyways.) + * + * We may not warn despite these conditions if we had shown the + * warning for that server before and the user acknowledged it. + * (Given that this dialog object is static/global and persistent, + * we can store that approval state here in this object.) + * + * @param configSchema @see open() + * @param configFilledIn @see open() + * @returns {boolean} - True when the dialog should be shown + * (call open()). if false, the dialog can and should be skipped. + */ + needed(configSchema, configFilledIn) { + assert(configSchema instanceof AccountConfig); + assert(configFilledIn instanceof AccountConfig); + assert(configSchema.isComplete()); + assert(configFilledIn.isComplete()); + + let incomingBad = + (configFilledIn.incoming.socketType > 1 ? 0 : this._inSecurityBad) | + (configFilledIn.incoming.badCert ? this._inCertBad : 0); + let outgoingBad = 0; + if (configFilledIn.outgoing.addThisServer) { + outgoingBad = + (configFilledIn.outgoing.socketType > 1 ? 0 : this._outSecurityBad) | + (configFilledIn.outgoing.badCert ? this._outCertBad : 0); + } + + if (incomingBad > 0) { + if ( + this._acknowledged.some(ackServer => { + return serverMatches(ackServer, configFilledIn.incoming); + }) + ) { + incomingBad = 0; + } + } + if (outgoingBad > 0) { + if ( + this._acknowledged.some(ackServer => { + return serverMatches(ackServer, configFilledIn.outgoing); + }) + ) { + outgoingBad = 0; + } + } + + return incomingBad | outgoingBad; + }, + + /** + * Opens the dialog, fills it with values, and shows it to the user. + * + * The function is async: it returns immediately, and when the user clicks + * OK or Cancel, the callbacks are called. There the callers proceed as + * appropriate. + * + * @param configSchema The config, with placeholders not replaced yet. + * This object may be modified to store the user's confirmations, but + * currently that's not the case. + * @param configFilledIn The concrete config with placeholders replaced. + * @param onlyIfNeeded {Boolean} - If there is nothing to warn about, + * call okCallback() immediately (and sync). + * @param okCallback {function(config {AccountConfig})} + * Called when the user clicked OK and approved the config including + * the warnings. |config| is without placeholders replaced. + * @param cancalCallback {function()} + * Called when the user decided to heed the warnings and not approve. + */ + open(configSchema, configFilledIn, onlyIfNeeded, okCallback, cancelCallback) { + assert(typeof okCallback == "function"); + assert(typeof cancelCallback == "function"); + + // needed() also checks the parameters + let needed = this.needed(configSchema, configFilledIn); + if (needed == 0 && onlyIfNeeded) { + okCallback(); + return; + } + + assert(needed > 0, "security dialog opened needlessly"); + + let dialog = document.getElementById("insecureDialog"); + this._currentConfigFilledIn = configFilledIn; + this._okCallback = okCallback; + this._cancelCallback = cancelCallback; + let incoming = configFilledIn.incoming; + let outgoing = configFilledIn.outgoing; + + // Reset the dialog, in case we've shown it before. + document.getElementById("acknowledgeWarning").checked = false; + document.getElementById("insecureConfirmButton").disabled = true; + + // Incoming security is bad. + let insecureIncoming = document.getElementById("insecureSectionIncoming"); + if (needed & this._inSecurityBad) { + document.l10n.setAttributes( + document.getElementById("warningIncoming"), + "account-setup-warning-cleartext", + { + server: incoming.hostname, + } + ); + + document.l10n.setAttributes( + document.getElementById("detailsIncoming"), + "account-setup-warning-cleartext-details" + ); + + insecureIncoming.hidden = false; + } else { + insecureIncoming.hidden = true; + } + + // Outgoing security or certificate is bad. + let insecureOutgoing = document.getElementById("insecureSectionOutgoing"); + if (needed & this._outSecurityBad) { + document.l10n.setAttributes( + document.getElementById("warningOutgoing"), + "account-setup-warning-cleartext", + { + server: outgoing.hostname, + } + ); + + document.l10n.setAttributes( + document.getElementById("detailsOutgoing"), + "account-setup-warning-cleartext-details" + ); + + insecureOutgoing.hidden = false; + } else { + insecureOutgoing.hidden = true; + } + + assert( + !insecureIncoming.hidden || !insecureOutgoing.hidden, + "warning dialog shown for unknown reason" + ); + + // Show the dialog. + dialog.showModal(); + }, + + /** + * User checked checkbox that he understood it and wishes to ignore the + * warning. + */ + toggleAcknowledge() { + document.getElementById("insecureConfirmButton").disabled = + !document.getElementById("acknowledgeWarning").checked; + }, + + /** + * [Cancel] button pressed. Get me out of here! + */ + onCancel() { + document.getElementById("insecureDialog").close(); + document.getElementById("incomingProtocol").focus(); + + this._cancelCallback(); + }, + + /** + * [OK] button pressed. + * Implies that the user toggled the acknowledge checkbox, + * i.e. approved the config and ignored the warnings, + * otherwise the button would have been disabled. + */ + onOK() { + assert(document.getElementById("acknowledgeWarning").checked); + + // Need filled in, in case the hostname is a placeholder. + let storeConfig = this._currentConfigFilledIn.copy(); + this._acknowledged.push(storeConfig.incoming); + this._acknowledged.push(storeConfig.outgoing); + + document.getElementById("insecureDialog").close(); + + this._okCallback(); + }, +}; + +/** + * Helper method to open the dictionaries list in a new tab. + */ +function openDictionariesTab() { + let mailWindow = Services.wm.getMostRecentWindow("mail:3pane"); + let tabmail = mailWindow.document.getElementById("tabmail"); + + let url = Services.urlFormatter.formatURLPref( + "spellchecker.dictionaries.download.url" + ); + + // Open the dictionaries URL. + tabmail.openTab("contentTab", { + url, + }); +} |