diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /comm/mail/components/accountcreation | |
parent | Initial commit. (diff) | |
download | thunderbird-upstream.tar.xz thunderbird-upstream.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'comm/mail/components/accountcreation')
24 files changed, 11234 insertions, 0 deletions
diff --git a/comm/mail/components/accountcreation/AccountConfig.jsm b/comm/mail/components/accountcreation/AccountConfig.jsm new file mode 100644 index 0000000000..59b9604725 --- /dev/null +++ b/comm/mail/components/accountcreation/AccountConfig.jsm @@ -0,0 +1,463 @@ +/* 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/. */ + +/** + * This file creates the class AccountConfig, which is a JS object that holds + * a configuration for a certain account. It is *not* created in the backend + * yet (use aw-createAccount.js for that), and it may be incomplete. + * + * Several AccountConfig objects may co-exist, e.g. for autoconfig. + * One AccountConfig object is used to prefill and read the widgets + * in the Wizard UI. + * When we autoconfigure, we autoconfig writes the values into a + * new object and returns that, and the caller can copy these + * values into the object used by the UI. + * + * See also + * <https://wiki.mozilla.org/Thunderbird:Autoconfiguration:ConfigFileFormat> + * for values stored. + */ + +const EXPORTED_SYMBOLS = ["AccountConfig"]; + +const lazy = {}; + +ChromeUtils.defineModuleGetter( + lazy, + "AccountCreationUtils", + "resource:///modules/accountcreation/AccountCreationUtils.jsm" +); +ChromeUtils.defineModuleGetter( + lazy, + "Sanitizer", + "resource:///modules/accountcreation/Sanitizer.jsm" +); + +function AccountConfig() { + this.incoming = this.createNewIncoming(); + this.incomingAlternatives = []; + this.outgoing = this.createNewOutgoing(); + this.outgoingAlternatives = []; + this.identity = { + // displayed real name of user + realname: "%REALNAME%", + // email address of user, as shown in From of outgoing mails + emailAddress: "%EMAILADDRESS%", + }; + this.inputFields = []; + this.domains = []; +} +AccountConfig.prototype = { + // @see createNewIncoming() + incoming: null, + // @see createNewOutgoing() + outgoing: null, + /** + * Other servers which can be used instead of |incoming|, + * in order of decreasing preference. + * (|incoming| itself should not be included here.) + * { Array of incoming/createNewIncoming() } + */ + incomingAlternatives: null, + outgoingAlternatives: null, + // just an internal string to refer to this. Do not show to user. + id: null, + // who created the config. + // { one of kSource* } + source: null, + /** + * Used for telemetry purposes. + * - for kSourceXML, subSource is one of xml-from-{disk, db, isp-https, isp-http}. + * - for kSourceExchange, subSource is one of exchange-from-urlN[-guess]. + */ + subSource: null, + displayName: null, + // { Array of { varname (value without %), displayName, exampleValue } } + inputFields: null, + // email address domains for which this config is applicable + // { Array of Strings } + domains: null, + + /** + * Factory function for incoming and incomingAlternatives + */ + createNewIncoming() { + return { + // { String-enum: "pop3", "imap", "nntp", "exchange" } + type: null, + hostname: null, + // { Integer } + port: null, + // May be a placeholder (starts and ends with %). { String } + username: null, + password: null, + // {nsMsgSocketType} @see MailNewsTypes2.idl. -1 means not inited + socketType: -1, + /** + * true when the cert is invalid (and thus SSL useless), because it's + * 1) not from an accepted CA (including self-signed certs) + * 2) for a different hostname or + * 3) expired. + * May go back to false when user explicitly accepted the cert. + */ + badCert: false, + /** + * How to log in to the server: plaintext or encrypted pw, GSSAPI etc. + * Defined by Ci.nsMsgAuthMethod + * Same as server pref "authMethod". + */ + auth: 0, + /** + * Other auth methods that we think the server supports. + * They are ordered by descreasing preference. + * (|auth| itself is not included in |authAlternatives|) + * {Array of Ci.nsMsgAuthMethod} (same as .auth) + */ + authAlternatives: null, + // in minutes { Integer } + checkInterval: 10, + loginAtStartup: true, + // POP3 only: + // Not yet implemented. { Boolean } + useGlobalInbox: false, + leaveMessagesOnServer: true, + daysToLeaveMessagesOnServer: 14, + deleteByAgeFromServer: true, + // When user hits delete, delete from local store and from server + deleteOnServerWhenLocalDelete: true, + downloadOnBiff: true, + // Override `addThisServer` for a specific incoming server + useGlobalPreferredServer: false, + + // OAuth2 configuration, if needed. + oauthSettings: null, + + // for Microsoft Exchange servers. Optional. + owaURL: null, + ewsURL: null, + easURL: null, + // for when an addon overrides the account type. Optional. + addonAccountType: null, + }; + }, + /** + * Factory function for outgoing and outgoingAlternatives + */ + createNewOutgoing() { + return { + type: "smtp", + hostname: null, + port: null, // see incoming + username: null, // see incoming. may be null, if auth is 0. + password: null, // see incoming. may be null, if auth is 0. + socketType: -1, // see incoming + badCert: false, // see incoming + auth: 0, // see incoming + authAlternatives: null, // see incoming + addThisServer: true, // if we already have an SMTP server, add this + // if we already have an SMTP server, use it. + useGlobalPreferredServer: false, + // we should reuse an already configured SMTP server. + // nsISmtpServer.key + existingServerKey: null, + // user display value for existingServerKey + existingServerLabel: null, + + // OAuth2 configuration, if needed. + oauthSettings: null, + }; + }, + + /** + * The configuration needs an addon to handle the account type. + * The addon needs to be installed before the account can be created + * in the backend. + * You can choose one, if there are several addons in the list. + * (Optional) + * + * Array of: + * { + * id: "owl@example.com" {string}, + * + * // already localized string + * name: "Owl" {string}, + * + * // already localized string + * description: "A third party addon that allows you to connect to Exchange servers" {string} + * + * // Minimal version of the addon. Needed in case the addon is already installed, + * // to verify that the installed version is sufficient. + * // The XPI URL below must satisfy this. + * // Must satisfy <https://developer.mozilla.org/en-US/docs/Mozilla/Toolkit_version_format> + * minVersion: "0.2" {string} + * + * xpiURL: "https://live.thunderbird.net/autoconfig/owl.xpi" {URL}, + * websiteURL: "https://www.beonex.com/owl/" {URL}, + * icon32: "https://www.beonex.com/owl/owl-32x32.png" {URL}, + * + * useType : { + * // Type shown as radio button to user in the config result. + * // Users won't understand OWA vs. EWS vs. EAS etc., so this is an abstraction + * // from the end user perspective. + * generalType: "exchange" {string}, + * + * // Protocol + * // Independent of the addon + * protocolType: "owa" {string}, + * + * // Account type in the Thunderbird backend. + * // What nsIMsgAccount.type will be set to when creating the account. + * // This is specific to the addon. + * addonAccountType: "owl-owa" {string}, + * } + * } + */ + addons: null, + + /** + * Returns a deep copy of this object, + * i.e. modifying the copy will not affect the original object. + */ + copy() { + // Workaround: deepCopy() fails to preserve base obj (instanceof) + let result = new AccountConfig(); + for (let prop in this) { + result[prop] = lazy.AccountCreationUtils.deepCopy(this[prop]); + } + + return result; + }, + + isComplete() { + return ( + !!this.incoming.hostname && + !!this.incoming.port && + this.incoming.socketType != -1 && + !!this.incoming.auth && + !!this.incoming.username && + (!!this.outgoing.existingServerKey || + this.outgoing.useGlobalPreferredServer || + (!!this.outgoing.hostname && + !!this.outgoing.port && + this.outgoing.socketType != -1 && + !!this.outgoing.auth && + !!this.outgoing.username)) + ); + }, + + toString() { + function sslToString(socketType) { + switch (socketType) { + case 0: + return "plain"; + case 2: + return "STARTTLS"; + case 3: + return "SSL"; + default: + return "invalid"; + } + } + + function authToString(authMethod) { + switch (authMethod) { + case 0: + return "undefined"; + case 1: + return "none"; + case 2: + return "old plain"; + case 3: + return "plain"; + case 4: + return "encrypted"; + case 5: + return "Kerberos"; + case 6: + return "NTLM"; + case 7: + return "external/SSL"; + case 8: + return "any secure"; + case 10: + return "OAuth2"; + default: + return "invalid"; + } + } + + function passwordToString(password) { + return password ? "set" : "not set"; + } + + function configToString(config) { + return ( + config.type + + ", " + + config.hostname + + ":" + + config.port + + ", " + + sslToString(config.socketType) + + ", auth: " + + authToString(config.auth) + + ", username: " + + (config.username || "(undefined)") + + ", password: " + + passwordToString(config.password) + ); + } + + let result = "Incoming: " + configToString(this.incoming) + "\nOutgoing: "; + if ( + this.outgoing.useGlobalPreferredServer || + this.incoming.useGlobalPreferredServer + ) { + result += "Use global server"; + } else if (this.outgoing.existingServerKey) { + result += "Use existing server " + this.outgoing.existingServerKey; + } else { + result += configToString(this.outgoing); + } + for (let config of this.incomingAlternatives) { + result += "\nIncoming alt: " + configToString(config); + } + for (let config of this.outgoingAlternatives) { + result += "\nOutgoing alt: " + configToString(config); + } + return result; + }, + + /** + * Sort the config alternatives such that exchange is the last of the + * alternatives. + */ + preferStandardProtocols() { + let alternatives = this.incomingAlternatives; + // Add default incoming as one alternative. + alternatives.unshift(this.incoming); + alternatives.sort((a, b) => { + if (a.type == "exchange") { + return 1; + } + if (b.type == "exchange") { + return -1; + } + return 0; + }); + this.incomingAlternatives = alternatives; + this.incoming = alternatives.shift(); + }, +}; + +// enum consts + +// .source +AccountConfig.kSourceUser = "user"; // user manually entered the config +AccountConfig.kSourceXML = "xml"; // config from XML from ISP or Mozilla DB +AccountConfig.kSourceGuess = "guess"; // guessConfig() +AccountConfig.kSourceExchange = "exchange"; // from Microsoft Exchange AutoDiscover + +/** + * Some fields on the account config accept placeholders (when coming from XML). + * + * These are the predefined ones + * %EMAILADDRESS% (full email address of the user, usually entered by user) + * %EMAILLOCALPART% (email address, part before @) + * %EMAILDOMAIN% (email address, part after @) + * %REALNAME% + * as well as those defined in account.inputFields.*.varname, with % added + * before and after. + * + * These must replaced with real values, supplied by the user or app, + * before the account is created. This is done here. You call this function once + * you have all the data - gathered the standard vars mentioned above as well as + * all listed in account.inputFields, and pass them in here. This function will + * insert them in the fields, returning a fully filled-out account ready to be + * created. + * + * @param account {AccountConfig} + * The account data to be modified. It may or may not contain placeholders. + * After this function, it should not contain placeholders anymore. + * This object will be modified in-place. + * + * @param emailfull {String} + * Full email address of this account, e.g. "joe@example.com". + * Empty of incomplete email addresses will/may be rejected. + * + * @param realname {String} + * Real name of user, as will appear in From of outgoing messages + * + * @param password {String} + * The password for the incoming server and (if necessary) the outgoing server + */ +AccountConfig.replaceVariables = function ( + account, + realname, + emailfull, + password +) { + lazy.Sanitizer.nonemptystring(emailfull); + let emailsplit = emailfull.split("@"); + lazy.AccountCreationUtils.assert( + emailsplit.length == 2, + "email address not in expected format: must contain exactly one @" + ); + let emaillocal = lazy.Sanitizer.nonemptystring(emailsplit[0]); + let emaildomain = lazy.Sanitizer.hostname(emailsplit[1]); + lazy.Sanitizer.label(realname); + lazy.Sanitizer.nonemptystring(realname); + + let otherVariables = {}; + otherVariables.EMAILADDRESS = emailfull; + otherVariables.EMAILLOCALPART = emaillocal; + otherVariables.EMAILDOMAIN = emaildomain; + otherVariables.REALNAME = realname; + + if (password) { + account.incoming.password = password; + account.outgoing.password = password; // set member only if auth required? + } + account.incoming.username = _replaceVariable( + account.incoming.username, + otherVariables + ); + account.outgoing.username = _replaceVariable( + account.outgoing.username, + otherVariables + ); + account.incoming.hostname = _replaceVariable( + account.incoming.hostname, + otherVariables + ); + if (account.outgoing.hostname) { + // will be null if user picked existing server. + account.outgoing.hostname = _replaceVariable( + account.outgoing.hostname, + otherVariables + ); + } + account.identity.realname = _replaceVariable( + account.identity.realname, + otherVariables + ); + account.identity.emailAddress = _replaceVariable( + account.identity.emailAddress, + otherVariables + ); + account.displayName = _replaceVariable(account.displayName, otherVariables); +}; + +function _replaceVariable(variable, values) { + let str = variable; + if (typeof str != "string") { + return str; + } + + for (let varname in values) { + str = str.replace("%" + varname + "%", values[varname]); + } + + return str; +} diff --git a/comm/mail/components/accountcreation/AccountCreationUtils.jsm b/comm/mail/components/accountcreation/AccountCreationUtils.jsm new file mode 100644 index 0000000000..f4efb96b2d --- /dev/null +++ b/comm/mail/components/accountcreation/AccountCreationUtils.jsm @@ -0,0 +1,717 @@ +/* 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/. */ +/** + * Some common, generic functions + */ + +const EXPORTED_SYMBOLS = ["AccountCreationUtils"]; + +const lazy = {}; + +ChromeUtils.defineModuleGetter( + lazy, + "Sanitizer", + "resource:///modules/accountcreation/Sanitizer.jsm" +); + +const { AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs" +); +const { ConsoleAPI } = ChromeUtils.importESModule( + "resource://gre/modules/Console.sys.mjs" +); +const { clearInterval, clearTimeout, setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); + +// -------------------------- +// Low level, basic functions + +function assert(test, errorMsg) { + if (!test) { + throw new NotReached( + errorMsg ? errorMsg : "Programming bug. Assertion failed, see log." + ); + } +} + +function makeCallback(obj, func) { + return func.bind(obj); +} + +/** + * Runs the given function sometime later + * + * Currently implemented using setTimeout(), but + * can later be replaced with an nsITimer impl, + * when code wants to use it in a module. + * + * @see |TimeoutAbortable| + */ +function runAsync(func) { + return setTimeout(func, 0); +} + +/** + * Reads UTF8 data from a URL. + * + * @param uri {nsIURI} - what you want to read + * @returns {Array of String} the contents of the file, one string per line + */ +function readURLasUTF8(uri) { + assert(uri instanceof Ci.nsIURI, "uri must be an nsIURI"); + let chan = Services.io.newChannelFromURI( + uri, + null, + Services.scriptSecurityManager.getSystemPrincipal(), + null, + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + Ci.nsIContentPolicy.TYPE_OTHER + ); + let is = Cc["@mozilla.org/intl/converter-input-stream;1"].createInstance( + Ci.nsIConverterInputStream + ); + is.init( + chan.open(), + "UTF-8", + 1024, + Ci.nsIConverterInputStream.DEFAULT_REPLACEMENT_CHARACTER + ); + + let content = ""; + let strOut = {}; + try { + while (is.readString(1024, strOut) != 0) { + content += strOut.value; + } + } finally { + is.close(); + } + + return content; + // TODO this has a numeric error message. We need to ship translations + // into human language. +} + +/** + * @param bundleURI {String} - chrome URL to properties file + * @returns nsIStringBundle + */ +function getStringBundle(bundleURI) { + try { + return Services.strings.createBundle(bundleURI); + } catch (e) { + throw new Exception( + "Failed to get stringbundle URI <" + bundleURI + ">. Error: " + e + ); + } +} + +// --------- +// Exception + +function Exception(msg) { + this._message = msg; + this.stack = Components.stack.formattedStack; +} +Exception.prototype = { + get message() { + return this._message; + }, + toString() { + return this._message; + }, +}; + +function NotReached(msg) { + Exception.call(this, msg); // call super constructor + console.error(this); +} +// Make NotReached extend Exception. +NotReached.prototype = Object.create(Exception.prototype); +NotReached.prototype.constructor = NotReached; + +// --------- +// Abortable + +/** + * A handle for an async function which you can cancel. + * The async function will return an object of this type (a subtype) + * and you can call cancel() when you feel like killing the function. + */ +function Abortable() {} +Abortable.prototype = { + cancel(e) {}, +}; + +function CancelledException(msg) { + Exception.call(this, msg); +} +CancelledException.prototype = Object.create(Exception.prototype); +CancelledException.prototype.constructor = CancelledException; + +function UserCancelledException(msg) { + // The user knows they cancelled so I don't see a need + // for a message to that effect. + if (!msg) { + msg = "User cancelled"; + } + CancelledException.call(this, msg); +} +UserCancelledException.prototype = Object.create(CancelledException.prototype); +UserCancelledException.prototype.constructor = UserCancelledException; + +/** + * Utility implementation, for waiting for a promise to resolve, + * but allowing its result to be cancelled. + */ +function PromiseAbortable(promise, successCallback, errorCallback) { + Abortable.call(this); // call super constructor + let complete = false; + this.cancel = function (e) { + if (!complete) { + complete = true; + errorCallback(e || new CancelledException()); + } + }; + promise + .then(function (result) { + if (!complete) { + successCallback(result); + complete = true; + } + }) + .catch(function (e) { + if (!complete) { + complete = true; + errorCallback(e); + } + }); +} +PromiseAbortable.prototype = Object.create(Abortable.prototype); +PromiseAbortable.prototype.constructor = PromiseAbortable; + +/** + * Utility implementation, for allowing to abort a setTimeout. + * Use like: return new TimeoutAbortable(setTimeout(function(){ ... }, 0)); + * + * @param setTimeoutID {Integer} - Return value of setTimeout() + */ +function TimeoutAbortable(setTimeoutID) { + Abortable.call(this); // call super constructor + this._id = setTimeoutID; +} +TimeoutAbortable.prototype = Object.create(Abortable.prototype); +TimeoutAbortable.prototype.constructor = TimeoutAbortable; +TimeoutAbortable.prototype.cancel = function () { + clearTimeout(this._id); +}; + +/** + * Utility implementation, for allowing to abort a setTimeout. + * Use like: return new TimeoutAbortable(setTimeout(function(){ ... }, 0)); + * + * @param setIntervalID {Integer} - Return value of setInterval() + */ +function IntervalAbortable(setIntervalID) { + Abortable.call(this); // call super constructor + this._id = setIntervalID; +} +IntervalAbortable.prototype = Object.create(Abortable.prototype); +IntervalAbortable.prototype.constructor = IntervalAbortable; +IntervalAbortable.prototype.cancel = function () { + clearInterval(this._id); +}; + +/** + * Allows you to make several network calls, + * but return only one |Abortable| object. + */ +function SuccessiveAbortable() { + Abortable.call(this); // call super constructor + this._current = null; +} +SuccessiveAbortable.prototype = { + __proto__: Abortable.prototype, + get current() { + return this._current; + }, + set current(abortable) { + assert( + abortable instanceof Abortable || abortable == null, + "need an Abortable object (or null)" + ); + this._current = abortable; + }, + cancel(e) { + if (this._current) { + this._current.cancel(e); + } + }, +}; + +/** + * Allows you to make several network calls in parallel. + */ +function ParallelAbortable() { + Abortable.call(this); // call super constructor + // { Array of ParallelCall } + this._calls = []; + // { Array of Function } + this._finishedObservers = []; +} +ParallelAbortable.prototype = { + __proto__: Abortable.prototype, + /** + * @returns {Array of ParallelCall} + */ + get results() { + return this._calls; + }, + /** + * @returns {ParallelCall} + */ + addCall() { + let call = new ParallelCall(this); + call.position = this._calls.length; + this._calls.push(call); + return call; + }, + /** + * Observers will be called once one of the functions + * finishes, i.e. returns successfully or fails. + * + * @param {Function({ParallelCall} call)} func + */ + addOneFinishedObserver(func) { + assert(typeof func == "function"); + this._finishedObservers.push(func); + }, + /** + * Will be called once *all* of the functions finished, + * It gives you a list of all functions that succeeded or failed, + * respectively. + * + * @param {Function( + * {Array of ParallelCall} succeeded, + * {Array of ParallelCall} failed + * )} func + */ + addAllFinishedObserver(func) { + assert(typeof func == "function"); + this.addOneFinishedObserver(() => { + if (this._calls.some(call => !call.finished)) { + return; + } + let succeeded = this._calls.filter(call => call.succeeded); + let failed = this._calls.filter(call => !call.succeeded); + func(succeeded, failed); + }); + }, + _notifyFinished(call) { + for (let observer of this._finishedObservers) { + try { + observer(call); + } catch (e) { + console.error(e); + } + } + }, + cancel(e) { + for (let call of this._calls) { + if (!call.finished && call.callerAbortable) { + call.callerAbortable.cancel(e); + } + } + }, +}; + +/** + * Returned by ParallelAbortable.addCall(). + * Do not create this object directly + * + * @param {ParallelAbortable} parallelAbortable - The controlling ParallelAbortable + */ +function ParallelCall(parallelAbortable) { + assert(parallelAbortable instanceof ParallelAbortable); + // {ParallelAbortable} the parent + this._parallelAbortable = parallelAbortable; + // {Abortable} Abortable of the caller function that should run in parallel + this.callerAbortable = null; + // {Integer} the order in which the function was added, and its priority + this.position = null; + // {boolean} false = running, pending, false = success or failure + this.finished = false; + // {boolean} if finished: true = returned with success, false = returned with error + this.succeeded = false; + // {Exception} if failed: the error or exception that the caller function returned + this.e = null; + // {Object} if succeeded: the result of the caller function + this.result = null; + + this._time = Date.now(); +} +ParallelCall.prototype = { + /** + * Returns a successCallback(result) function that you pass + * to your function that runs in parallel. + * + * @returns {Function(result)} successCallback + */ + successCallback() { + return result => { + ddump( + "call " + + this.position + + " took " + + (Date.now() - this._time) + + "ms and succeeded" + + (this.callerAbortable && this.callerAbortable._url + ? " at <" + this.callerAbortable._url + ">" + : "") + ); + this.result = result; + this.finished = true; + this.succeeded = true; + this._parallelAbortable._notifyFinished(this); + }; + }, + /** + * Returns an errorCallback(e) function that you pass + * to your function that runs in parallel. + * + * @returns {Function(e)} errorCallback + */ + errorCallback() { + return e => { + ddump( + "call " + + this.position + + " took " + + (Date.now() - this._time) + + "ms and failed with " + + (typeof e.code == "number" ? e.code + " " : "") + + (e.toString() + ? e.toString() + : "unknown error, probably no host connection") + + (this.callerAbortable && this.callerAbortable._url + ? " at <" + this.callerAbortable._url + ">" + : "") + ); + this.e = e; + this.finished = true; + this.succeeded = false; + this._parallelAbortable._notifyFinished(this); + }; + }, + /** + * Call your function that needs to run in parallel + * and pass the resulting |Abortable| of your function here. + * + * @param {Abortable} abortable + */ + setAbortable(abortable) { + assert(abortable instanceof Abortable); + this.callerAbortable = abortable; + }, +}; + +/** + * Runs several calls in parallel. + * Returns the result of the "highest" priority call that succeeds. + * Unlike Promise.race(), does not return the fastest, + * but the first in the order they were added. + * So, the order in which the calls were added determines their priority, + * with the first to be added being the most desirable. + * + * E.g. the first failed, the second is pending, the third succeeded, and the forth is pending. + * It aborts the forth (because the third succeeded), and it waits for the second to return. + * If the second succeeds, it is the result, otherwise the third is the result. + * + * @param {Function( + * {Object} result - Result of winner call + * {ParallelCall} call - Winner call info + * )} successCallback - A call returned successfully + * @param {Function(e, allErrors)} errorCallback - All calls failed. + * {Exception} e - The first CancelledException, and otherwise + * the exception returned by the first call. + * This is just to adhere to the standard API of errorCallback(e). + * {Array of Exception} allErrors - The exceptions from all calls. + */ +function PriorityOrderAbortable(successCallback, errorCallback) { + assert(typeof successCallback == "function"); + assert(typeof errorCallback == "function"); + ParallelAbortable.call(this); // call super constructor + this._successfulCall = null; + + this.addOneFinishedObserver(finishedCall => { + for (let call of this._calls) { + if (!call.finished) { + if (this._successfulCall) { + // abort + if (call.callerAbortable) { + call.callerAbortable.cancel( + new NoLongerNeededException("Another higher call succeeded") + ); + } + continue; + } + // It's pending. do nothing and wait for it. + return; + } + if (!call.succeeded) { + // it failed. ignore it. + continue; + } + if (this._successfulCall) { + // we already have a winner. ignore it. + continue; + } + try { + successCallback(call.result, call); + // This is the winner. + this._successfulCall = call; + } catch (e) { + console.error(e); + // If the handler failed with this data, treat this call as failed. + call.e = e; + call.succeeded = false; + } + } + if (!this._successfulCall) { + // all failed + let allErrors = this._calls.map(call => call.e); + let e = + allErrors.find(e => e instanceof CancelledException) || allErrors[0]; + errorCallback(e, allErrors); // see docs above + } + }); +} +PriorityOrderAbortable.prototype = Object.create(ParallelAbortable.prototype); +PriorityOrderAbortable.prototype.constructor = PriorityOrderAbortable; + +function NoLongerNeededException(msg) { + CancelledException.call(this, msg); +} +NoLongerNeededException.prototype = Object.create(CancelledException.prototype); +NoLongerNeededException.prototype.constructor = NoLongerNeededException; + +// ------------------- +// High level features + +/** + * Allows you to install an addon. + * + * Example: + * var installer = new AddonInstaller({ xpiURL : "https://...xpi", id: "...", ...}); + * installer.install(); + * + * @param {object} args - Contains parameters: + * @param {string} name (Optional) - Name of the addon (not important) + * @param {string} id (Optional) - Addon ID + * If you pass an ID, and the addon is already installed (and the version matches), + * then install() will do nothing. + * After the XPI is downloaded, the ID will be verified. If it doesn't match, the + * install will fail. + * If you don't pass an ID, these checks will be skipped and the addon be installed + * unconditionally. + * It is recommended to pass at least an ID, because it can confuse some addons + * to be reloaded at runtime. + * @param {string} minVersion (Optional) - Minimum version of the addon + * If you pass a minVersion (in addition to ID), and the installed addon is older than this, + * the install will be done anyway. If the downloaded addon has a lower version, + * the install will fail. + * If you do not pass a minVersion, there will be no version check. + * @param {URL} xpiURL - Where to download the XPI from + */ +function AddonInstaller(args) { + Abortable.call(this); + this._name = lazy.Sanitizer.label(args.name); + this._id = lazy.Sanitizer.string(args.id); + this._minVersion = lazy.Sanitizer.string(args.minVersion); + this._url = lazy.Sanitizer.url(args.xpiURL); +} +AddonInstaller.prototype = Object.create(Abortable.prototype); +AddonInstaller.prototype.constructor = AddonInstaller; + +/** + * Checks whether the passed-in addon matches the + * id and minVersion requested by the caller. + * + * @param {nsIAddon} addon + * @returns {boolean} is OK + */ +AddonInstaller.prototype.matches = function (addon) { + return ( + !this._id || + (this._id == addon.id && + (!this._minVersion || + Services.vc.compare(addon.version, this._minVersion) >= 0)) + ); +}; + +/** + * Start the installation + * + * @throws Exception in case of failure + */ +AddonInstaller.prototype.install = async function () { + if (await this.isInstalled()) { + return; + } + await this._installDirect(); +}; + +/** + * Checks whether we already have an addon installed that matches the + * id and minVersion requested by the caller. + * + * @returns {boolean} is already installed and enabled + */ +AddonInstaller.prototype.isInstalled = async function () { + if (!this._id) { + return false; + } + var addon = await AddonManager.getAddonByID(this._id); + return addon && this.matches(addon) && addon.isActive; +}; + +/** + * Checks whether we already have an addon but it is disabled. + * + * @returns {boolean} is already installed but disabled + */ +AddonInstaller.prototype.isDisabled = async function () { + if (!this._id) { + return false; + } + let addon = await AddonManager.getAddonByID(this._id); + return addon && !addon.isActive; +}; + +/** + * Downloads and installs the addon. + * The downloaded XPI will be checked using prompt(). + */ +AddonInstaller.prototype._installDirect = async function () { + var installer = (this._installer = await AddonManager.getInstallForURL( + this._url, + { name: this._name } + )); + installer.promptHandler = makeCallback(this, this.prompt); + await installer.install(); // throws, if failed + + var addon = await AddonManager.getAddonByID(this._id); + await addon.enable(); + + // Wait for addon startup code to finish + // Fixes: verify password fails with NOT_AVAILABLE in createIncomingServer() + if ("startupPromise" in addon) { + await addon.startupPromise; + } + let wait = ms => new Promise(resolve => setTimeout(resolve, ms)); + await wait(1000); +}; + +/** + * Install confirmation. You may override this, if needed. + * + * @throws Exception If you want to cancel install, then throw an exception. + */ +AddonInstaller.prototype.prompt = async function (info) { + if (!this.matches(info.addon)) { + // happens only when we got the wrong XPI + throw new Exception( + "The downloaded addon XPI does not match the minimum requirements" + ); + } +}; + +AddonInstaller.prototype.cancel = function () { + if (this._installer) { + try { + this._installer.cancel(); + } catch (e) { + // if install failed + ddump(e); + } + } +}; + +// ------------ +// Debug output + +function deepCopy(org) { + if (typeof org == "undefined") { + return undefined; + } + if (org == null) { + return null; + } + if (typeof org == "string") { + return org; + } + if (typeof org == "number") { + return org; + } + if (typeof org == "boolean") { + return org; + } + if (typeof org == "function") { + return org; + } + if (typeof org != "object") { + throw new Error("can't copy objects of type " + typeof org + " yet"); + } + + // TODO still instanceof org != instanceof copy + // var result = new org.constructor(); + var result = {}; + if (typeof org.length != "undefined") { + result = []; + } + for (var prop in org) { + result[prop] = deepCopy(org[prop]); + } + return result; +} + +var gAccountSetupLogger = new ConsoleAPI({ + prefix: "mail.setup", + maxLogLevel: "warn", + maxLogLevelPref: "mail.setup.loglevel", +}); + +function ddump(text) { + gAccountSetupLogger.info(text); +} + +function alertPrompt(alertTitle, alertMsg) { + Services.prompt.alert( + Services.wm.getMostRecentWindow(""), + alertTitle, + alertMsg + ); +} + +var AccountCreationUtils = { + Abortable, + AddonInstaller, + alertPrompt, + assert, + CancelledException, + ddump, + deepCopy, + Exception, + gAccountSetupLogger, + getStringBundle, + NotReached, + PriorityOrderAbortable, + PromiseAbortable, + readURLasUTF8, + runAsync, + SuccessiveAbortable, + TimeoutAbortable, + UserCancelledException, +}; diff --git a/comm/mail/components/accountcreation/ConfigVerifier.jsm b/comm/mail/components/accountcreation/ConfigVerifier.jsm new file mode 100644 index 0000000000..cce934a159 --- /dev/null +++ b/comm/mail/components/accountcreation/ConfigVerifier.jsm @@ -0,0 +1,386 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const EXPORTED_SYMBOLS = ["ConfigVerifier"]; + +const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +const { OAuth2Providers } = ChromeUtils.import( + "resource:///modules/OAuth2Providers.jsm" +); +const { AccountCreationUtils } = ChromeUtils.import( + "resource:///modules/accountcreation/AccountCreationUtils.jsm" +); + +/** + * @implements {nsIUrlListener} + * @implements {nsIInterfaceRequestor} + */ +class ConfigVerifier { + QueryInterface = ChromeUtils.generateQI([ + "nsIInterfaceRequestor", + "nsIUrlListener", + ]); + + // @see {nsIInterfaceRequestor} + getInterface(iid) { + return this.QueryInterface(iid); + } + + constructor(msgWindow) { + this.msgWindow = msgWindow; + this._log = console.createInstance({ + prefix: "mail.setup", + maxLogLevel: "Warn", + maxLogLevelPref: "mail.setup.loglevel", + }); + } + + /** + * @param {nsIURI} url - The URL being processed. + * @see {nsIUrlListener} + */ + OnStartRunningUrl(url) { + this._log.debug(`Starting to verify configuration; + email as username=${ + this.config.incoming.username != this.config.identity.emailAddress + } + savedUsername=${this.config.usernameSaved ? "true" : "false"}, + authMethod=${this.server.authMethod}`); + } + + /** + * @param {nsIURI} url - The URL being processed. + * @param {nsresult} status - A result code of URL processing. + * @see {nsIUrlListener} + */ + OnStopRunningUrl(url, status) { + if (Components.isSuccessCode(status)) { + this._log.debug(`Configuration verified successfully!`); + this.cleanup(); + this.successCallback(this.config); + return; + } + + this._log.debug(`Verifying configuration failed; status=${status}`); + + let certError = false; + try { + let nssErrorsService = Cc["@mozilla.org/nss_errors_service;1"].getService( + Ci.nsINSSErrorsService + ); + let errorClass = nssErrorsService.getErrorClass(status); + if (errorClass == Ci.nsINSSErrorsService.ERROR_CLASS_BAD_CERT) { + certError = true; + } + } catch (e) { + // It's not an NSS error. + } + + if (certError) { + let mailNewsUrl = url.QueryInterface(Ci.nsIMsgMailNewsUrl); + let secInfo = mailNewsUrl.failedSecInfo; + this.informUserOfCertError(secInfo, url.asciiHostPort); + } else if (this.alter) { + // Try other variations. + this.server.closeCachedConnections(); + this.tryNextLogon(url); + } else { + // Logon failed, and we aren't supposed to try other variations. + this._failed(url); + } + } + + tryNextLogon(aPreviousUrl) { + this._log.debug("Trying next logon variation"); + // check if we tried full email address as username + if (this.config.incoming.username != this.config.identity.emailAddress) { + this._log.debug("Changing username to email address."); + this.config.usernameSaved = this.config.incoming.username; + this.config.incoming.username = this.config.identity.emailAddress; + this.config.outgoing.username = this.config.identity.emailAddress; + this.server.username = this.config.incoming.username; + this.server.password = this.config.incoming.password; + this.verifyLogon(); + return; + } + + if (this.config.usernameSaved) { + this._log.debug("Re-setting username."); + // If we tried the full email address as the username, then let's go + // back to trying just the username before trying the other cases. + this.config.incoming.username = this.config.usernameSaved; + this.config.outgoing.username = this.config.usernameSaved; + this.config.usernameSaved = null; + this.server.username = this.config.incoming.username; + this.server.password = this.config.incoming.password; + } + + // sec auth seems to have failed, and we've tried both + // varieties of user name, sadly. + // So fall back to non-secure auth, and + // again try the user name and email address as username + if (this.server.socketType == Ci.nsMsgSocketType.SSL) { + this._log.debug("Using SSL"); + } else if (this.server.socketType == Ci.nsMsgSocketType.alwaysSTARTTLS) { + this._log.debug("Using STARTTLS"); + } + if ( + this.config.incoming.authAlternatives && + this.config.incoming.authAlternatives.length + ) { + // We may be dropping back to insecure auth methods here, + // which is not good. But then again, we already warned the user, + // if it is a config without SSL. + + let brokenAuth = this.config.incoming.auth; + // take the next best method (compare chooseBestAuthMethod() in guess) + this.config.incoming.auth = this.config.incoming.authAlternatives.shift(); + this.server.authMethod = this.config.incoming.auth; + // Assume that SMTP server has same methods working as incoming. + // Broken assumption, but we currently have no SMTP verification. + // TODO: implement real SMTP verification + if ( + this.config.outgoing.auth == brokenAuth && + this.config.outgoing.authAlternatives.includes( + this.config.incoming.auth + ) + ) { + this.config.outgoing.auth = this.config.incoming.auth; + } + this._log.debug(`Trying next auth method: ${this.server.authMethod}`); + this.verifyLogon(); + return; + } + + // Tried all variations we can. Give up. + this._log.debug("Have tried all variations. Giving up."); + this._failed(aPreviousUrl); + } + + /** + * Clear out the server we had created for use during testing. + */ + cleanup() { + try { + if (this.server) { + MailServices.accounts.removeIncomingServer(this.server, true); + this.server = null; + } + } catch (e) { + this._log.error(e); + } + } + + /** + * @param {nsIURI} url - The URL being processed. + */ + _failed(url) { + this.cleanup(); + url = url.QueryInterface(Ci.nsIMsgMailNewsUrl); + let code = url.errorCode || "login-error-unknown"; + let msg = url.errorMessage; + // *Only* for known (!) username/password errors, show our message. + // But there are 1000 other reasons why it could have failed, e.g. + // server not reachable, bad auth method, server hiccups, or even + // custom server messages that tell the user to do something, + // so show the backend error message, unless we are certain + // that it's a wrong username or password. + if ( + !msg || // Normal IMAP login error sets no error msg + code == "pop3UsernameFailure" || + code == "pop3PasswordFailed" || + code == "imapOAuth2Error" + ) { + msg = AccountCreationUtils.getStringBundle( + "chrome://messenger/locale/accountCreationModel.properties" + ).GetStringFromName("cannot_login.error"); + } + this.errorCallback(new Error(msg)); + } + + /** + * Inform users that we got a certificate error for the specified location. + * Allow them to add an exception for it. + * + * @param {nsITransportSecurityInfo} secInfo + * @param {string} location - "host:port" that had the problem. + */ + informUserOfCertError(secInfo, location) { + this._log.debug(`Informing user about cert error for ${location}`); + let params = { + exceptionAdded: false, + securityInfo: secInfo, + prefetchCert: true, + location, + }; + Services.wm + .getMostRecentWindow("mail:3pane") + .browsingContext.topChromeWindow.openDialog( + "chrome://pippki/content/exceptionDialog.xhtml", + "exceptionDialog", + "chrome,centerscreen,modal", + params + ); + if (!params.exceptionAdded) { + this._log.debug(`Did not accept exception for ${location}`); + this.cleanup(); + let errorMsg = AccountCreationUtils.getStringBundle( + "chrome://messenger/locale/accountCreationModel.properties" + ).GetStringFromName("cannot_login.error"); + this.errorCallback(new Error(errorMsg)); + } else { + this._log.debug(`Accept exception for ${location} - will retry logon.`); + // Retry the logon now that we've added the cert exception. + this.verifyLogon(); + } + } + + /** + * This checks a given config, by trying a real connection and login, + * with username and password. + * + * @param {AccountConfig} config - The guessed account config. + * username, password, realname, emailaddress etc. are not filled out, + * but placeholders to be filled out via replaceVariables(). + * @param alter {boolean} - Try other usernames and login schemes, until + * login works. Warning: Modifies |config|. + * @returns {Promise<AccountConfig>} the successful configuration. + * @throws {Error} when we could guess not the config, either + * because we have not found anything or because there was an error + * (e.g. no network connection). + * The ex.message will contain a user-presentable message. + */ + async verifyConfig(config, alter) { + this.alter = alter; + return new Promise((resolve, reject) => { + this.config = config; + this.successCallback = resolve; + this.errorCallback = reject; + if ( + MailServices.accounts.findServer( + config.incoming.username, + config.incoming.hostname, + config.incoming.type, + config.incoming.port + ) + ) { + reject(new Error("Incoming server exists")); + return; + } + + // incoming server + if (!this.server) { + this.server = MailServices.accounts.createIncomingServer( + config.incoming.username, + config.incoming.hostname, + config.incoming.type + ); + } + this.server.port = config.incoming.port; + this.server.password = config.incoming.password; + this.server.socketType = config.incoming.socketType; + + this._log.info( + "Setting incoming server authMethod to " + config.incoming.auth + ); + this.server.authMethod = config.incoming.auth; + + try { + // Lookup OAuth2 issuer if needed. + // -- Incoming. + if ( + config.incoming.auth == Ci.nsMsgAuthMethod.OAuth2 && + (!config.incoming.oauthSettings || + !config.incoming.oauthSettings.issuer || + !config.incoming.oauthSettings.scope) + ) { + let details = OAuth2Providers.getHostnameDetails( + config.incoming.hostname + ); + if (!details) { + reject( + new Error( + `Could not get OAuth2 details for hostname=${config.incoming.hostname}.` + ) + ); + } + config.incoming.oauthSettings = { + issuer: details[0], + scope: details[1], + }; + } + // -- Outgoing. + if ( + config.outgoing.auth == Ci.nsMsgAuthMethod.OAuth2 && + (!config.outgoing.oauthSettings || + !config.outgoing.oauthSettings.issuer || + !config.outgoing.oauthSettings.scope) + ) { + let details = OAuth2Providers.getHostnameDetails( + config.outgoing.hostname + ); + if (!details) { + reject( + new Error( + `Could not get OAuth2 details for hostname=${config.outgoing.hostname}.` + ) + ); + } + config.outgoing.oauthSettings = { + issuer: details[0], + scope: details[1], + }; + } + if (config.incoming.owaURL) { + this.server.setUnicharValue("owa_url", config.incoming.owaURL); + } + if (config.incoming.ewsURL) { + this.server.setUnicharValue("ews_url", config.incoming.ewsURL); + } + if (config.incoming.easURL) { + this.server.setUnicharValue("eas_url", config.incoming.easURL); + } + + if ( + this.server.password || + this.server.authMethod == Ci.nsMsgAuthMethod.OAuth2 + ) { + this.verifyLogon(); + } else { + this.cleanup(); + resolve(config); + } + } catch (e) { + this._log.info("verifyConfig failed: " + e); + this.cleanup(); + reject(e); + } + }); + } + + /** + * Verify that the provided credentials can log in to the incoming server. + */ + verifyLogon() { + this._log.info("verifyLogon for server at " + this.server.hostName); + // Save away the old callbacks. + let saveCallbacks = this.msgWindow.notificationCallbacks; + // Set our own callbacks - this works because verifyLogon will + // synchronously create the transport and use the notification callbacks. + // Our listener listens both for the url and cert errors. + this.msgWindow.notificationCallbacks = this; + // try to work around bug where backend is clearing password. + try { + this.server.password = this.config.incoming.password; + let uri = this.server.verifyLogon(this, this.msgWindow); + // clear msgWindow so url won't prompt for passwords. + uri.QueryInterface(Ci.nsIMsgMailNewsUrl).msgWindow = null; + } finally { + // restore them + this.msgWindow.notificationCallbacks = saveCallbacks; + } + } +} diff --git a/comm/mail/components/accountcreation/CreateInBackend.jsm b/comm/mail/components/accountcreation/CreateInBackend.jsm new file mode 100644 index 0000000000..c254bbb44b --- /dev/null +++ b/comm/mail/components/accountcreation/CreateInBackend.jsm @@ -0,0 +1,459 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const EXPORTED_SYMBOLS = ["CreateInBackend"]; + +const lazy = {}; + +ChromeUtils.defineModuleGetter( + lazy, + "AccountConfig", + "resource:///modules/accountcreation/AccountConfig.jsm" +); +ChromeUtils.defineModuleGetter( + lazy, + "AccountCreationUtils", + "resource:///modules/accountcreation/AccountCreationUtils.jsm" +); + +const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); + +/* eslint-disable complexity */ +/** + * Takes an |AccountConfig| JS object and creates that account in the + * Thunderbird backend (which also writes it to prefs). + * + * @param {AccountConfig} config - The account to create + * @returns {nsIMsgAccount} - the newly created account + */ +function createAccountInBackend(config) { + // incoming server + let inServer = MailServices.accounts.createIncomingServer( + config.incoming.username, + config.incoming.hostname, + config.incoming.type + ); + inServer.port = config.incoming.port; + inServer.authMethod = config.incoming.auth; + inServer.password = config.incoming.password; + // This new CLIENTID is for the outgoing server, and will be applied to the + // incoming only if the incoming username and hostname match the outgoing. + // We must generate this unconditionally because we cannot determine whether + // the outgoing server has clientid enabled yet or not, and we need to do it + // here in order to populate the incoming server if the outgoing matches. + let newOutgoingClientid = Services.uuid + .generateUUID() + .toString() + .replace(/[{}]/g, ""); + // Grab the base domain of both incoming and outgoing hostname in order to + // compare the two to detect if the base domain is the same. + let incomingBaseDomain; + let outgoingBaseDomain; + try { + incomingBaseDomain = Services.eTLD.getBaseDomainFromHost( + config.incoming.hostname + ); + } catch (e) { + incomingBaseDomain = config.incoming.hostname; + } + try { + outgoingBaseDomain = Services.eTLD.getBaseDomainFromHost( + config.outgoing.hostname + ); + } catch (e) { + outgoingBaseDomain = config.outgoing.hostname; + } + if ( + config.incoming.username == config.outgoing.username && + incomingBaseDomain == outgoingBaseDomain + ) { + inServer.clientid = newOutgoingClientid; + } else { + // If the username/hostname are different then generate a new CLIENTID. + inServer.clientid = Services.uuid + .generateUUID() + .toString() + .replace(/[{}]/g, ""); + } + + if (config.rememberPassword && config.incoming.password) { + rememberPassword(inServer, config.incoming.password); + } + + if (inServer.authMethod == Ci.nsMsgAuthMethod.OAuth2) { + inServer.setUnicharValue( + "oauth2.scope", + config.incoming.oauthSettings.scope + ); + inServer.setUnicharValue( + "oauth2.issuer", + config.incoming.oauthSettings.issuer + ); + } + + // SSL + inServer.socketType = config.incoming.socketType; + + // If we already have an account with an identical name, generate a unique + // name for the new account to avoid duplicates. + inServer.prettyName = checkAccountNameAlreadyExists( + config.identity.emailAddress + ) + ? generateUniqueAccountName(config) + : config.identity.emailAddress; + + inServer.doBiff = true; + inServer.biffMinutes = config.incoming.checkInterval; + inServer.setBoolValue("login_at_startup", config.incoming.loginAtStartup); + if (config.incoming.type == "pop3") { + inServer.setBoolValue( + "leave_on_server", + config.incoming.leaveMessagesOnServer + ); + inServer.setIntValue( + "num_days_to_leave_on_server", + config.incoming.daysToLeaveMessagesOnServer + ); + inServer.setBoolValue( + "delete_mail_left_on_server", + config.incoming.deleteOnServerWhenLocalDelete + ); + inServer.setBoolValue( + "delete_by_age_from_server", + config.incoming.deleteByAgeFromServer + ); + inServer.setBoolValue("download_on_biff", config.incoming.downloadOnBiff); + } + if (config.incoming.owaURL) { + inServer.setUnicharValue("owa_url", config.incoming.owaURL); + } + if (config.incoming.ewsURL) { + inServer.setUnicharValue("ews_url", config.incoming.ewsURL); + } + if (config.incoming.easURL) { + inServer.setUnicharValue("eas_url", config.incoming.easURL); + } + inServer.valid = true; + + let username = + config.outgoing.auth != Ci.nsMsgAuthMethod.none + ? config.outgoing.username + : null; + let outServer = MailServices.smtp.findServer( + username, + config.outgoing.hostname + ); + lazy.AccountCreationUtils.assert( + config.outgoing.addThisServer || + config.outgoing.useGlobalPreferredServer || + config.outgoing.existingServerKey, + "No SMTP server: inconsistent flags" + ); + + if ( + config.outgoing.addThisServer && + !outServer && + !config.incoming.useGlobalPreferredServer + ) { + outServer = MailServices.smtp.createServer(); + outServer.hostname = config.outgoing.hostname; + outServer.port = config.outgoing.port; + outServer.authMethod = config.outgoing.auth; + // Populate the clientid if it is enabled for this outgoing server. + if (outServer.clientidEnabled) { + outServer.clientid = newOutgoingClientid; + } + if (config.outgoing.auth != Ci.nsMsgAuthMethod.none) { + outServer.username = username; + outServer.password = config.outgoing.password; + if (config.rememberPassword && config.outgoing.password) { + rememberPassword(outServer, config.outgoing.password); + } + } + + if (outServer.authMethod == Ci.nsMsgAuthMethod.OAuth2) { + let prefBranch = "mail.smtpserver." + outServer.key + "."; + Services.prefs.setCharPref( + prefBranch + "oauth2.scope", + config.outgoing.oauthSettings.scope + ); + Services.prefs.setCharPref( + prefBranch + "oauth2.issuer", + config.outgoing.oauthSettings.issuer + ); + } + + outServer.socketType = config.outgoing.socketType; + outServer.description = config.displayName; + + // If this is the first SMTP server, set it as default + if ( + !MailServices.smtp.defaultServer || + !MailServices.smtp.defaultServer.hostname + ) { + MailServices.smtp.defaultServer = outServer; + } + } + + // identity + // TODO accounts without identity? + let identity = MailServices.accounts.createIdentity(); + identity.fullName = config.identity.realname; + identity.email = config.identity.emailAddress; + + // for new accounts, default to replies being positioned above the quote + // if a default account is defined already, take its settings instead + if (config.incoming.type == "imap" || config.incoming.type == "pop3") { + identity.replyOnTop = 1; + // identity.sigBottom = false; // don't set this until Bug 218346 is fixed + + if ( + MailServices.accounts.accounts.length && + MailServices.accounts.defaultAccount + ) { + let defAccount = MailServices.accounts.defaultAccount; + let defIdentity = defAccount.defaultIdentity; + if ( + defAccount.incomingServer.canBeDefaultServer && + defIdentity && + defIdentity.valid + ) { + identity.replyOnTop = defIdentity.replyOnTop; + identity.sigBottom = defIdentity.sigBottom; + } + } + } + + // due to accepted conventions, news accounts should default to plain text + if (config.incoming.type == "nntp") { + identity.composeHtml = false; + } + + identity.valid = true; + + if ( + !config.outgoing.useGlobalPreferredServer && + !config.incoming.useGlobalPreferredServer + ) { + if (config.outgoing.existingServerKey) { + identity.smtpServerKey = config.outgoing.existingServerKey; + } else { + identity.smtpServerKey = outServer.key; + } + } + + // account and hook up + // Note: Setting incomingServer will cause the AccountManager to refresh + // itself, which could be a problem if we came from it and we haven't set + // the identity (see bug 521955), so make sure everything else on the + // account is set up before you set the incomingServer. + let account = MailServices.accounts.createAccount(); + account.addIdentity(identity); + account.incomingServer = inServer; + if ( + inServer.canBeDefaultServer && + (!MailServices.accounts.defaultAccount || + !MailServices.accounts.defaultAccount.incomingServer.canBeDefaultServer) + ) { + MailServices.accounts.defaultAccount = account; + } + + verifyLocalFoldersAccount(MailServices.accounts); + setFolders(identity, inServer); + + // save + MailServices.accounts.saveAccountInfo(); + try { + Services.prefs.savePrefFile(null); + } catch (ex) { + lazy.AccountCreationUtils.ddump("Could not write out prefs: " + ex); + } + return account; +} +/* eslint-enable complexity */ + +function setFolders(identity, server) { + // TODO: support for local folders for global inbox (or use smart search + // folder instead) + + var baseURI = server.serverURI + "/"; + + // Names will be localized in UI, not in folder names on server/disk + // TODO allow to override these names in the XML config file, + // in case e.g. Google or AOL use different names? + // Workaround: Let user fix it :) + var fccName = "Sent"; + var draftName = "Drafts"; + var templatesName = "Templates"; + + identity.draftFolder = baseURI + draftName; + identity.stationeryFolder = baseURI + templatesName; + identity.fccFolder = baseURI + fccName; + + identity.fccFolderPickerMode = 0; + identity.draftsFolderPickerMode = 0; + identity.tmplFolderPickerMode = 0; +} + +function rememberPassword(server, password) { + let passwordURI; + if (server instanceof Ci.nsIMsgIncomingServer) { + passwordURI = server.localStoreType + "://" + server.hostName; + } else if (server instanceof Ci.nsISmtpServer) { + passwordURI = "smtp://" + server.hostname; + } else { + throw new lazy.AccountCreationUtils.NotReached("Server type not supported"); + } + + let login = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance( + Ci.nsILoginInfo + ); + login.init(passwordURI, null, passwordURI, server.username, password, "", ""); + try { + Services.logins.addLogin(login); + } catch (e) { + if (e.message.includes("This login already exists")) { + // TODO modify + } else { + throw e; + } + } +} + +/** + * Check whether the user's setup already has an incoming server + * which matches (hostname, port, username) the primary one + * in the config. + * (We also check the email address as username.) + * + * @param config {AccountConfig} filled in (no placeholders) + * @returns {nsIMsgIncomingServer} If it already exists, the server + * object is returned. + * If it's a new server, |null| is returned. + */ +function checkIncomingServerAlreadyExists(config) { + lazy.AccountCreationUtils.assert(config instanceof lazy.AccountConfig); + let incoming = config.incoming; + let existing = MailServices.accounts.findServer( + incoming.username, + incoming.hostname, + incoming.type, + incoming.port + ); + + // if username does not have an '@', also check the e-mail + // address form of the name. + if (!existing && !incoming.username.includes("@")) { + existing = MailServices.accounts.findServer( + config.identity.emailAddress, + incoming.hostname, + incoming.type, + incoming.port + ); + } + return existing; +} + +/** + * Check whether the user's setup already has an outgoing server + * which matches (hostname, port, username) the primary one + * in the config. + * + * @param config {AccountConfig} filled in (no placeholders) + * @returns {nsISmtpServer} If it already exists, the server + * object is returned. + * If it's a new server, |null| is returned. + */ +function checkOutgoingServerAlreadyExists(config) { + lazy.AccountCreationUtils.assert(config instanceof lazy.AccountConfig); + for (let existingServer of MailServices.smtp.servers) { + // TODO check username with full email address, too, like for incoming + if ( + existingServer.hostname == config.outgoing.hostname && + existingServer.port == config.outgoing.port && + existingServer.username == config.outgoing.username + ) { + return existingServer; + } + } + return null; +} + +/** + * Check whether the user's setup already has an account with the same email + * address. This might happen if the user uses the same email for different + * protocols (eg. IMAP and POP3). + * + * @param {string} name - The name or email address of the new account. + * @returns {boolean} True if an account with the same name is found. + */ +function checkAccountNameAlreadyExists(name) { + return MailServices.accounts.accounts.some( + a => a.incomingServer.prettyName == name + ); +} + +/** + * Generate a unique account name by appending the incoming protocol type, and + * a counter if necessary. + * + * @param {AccountConfig} config - The config data of the account being created. + * @returns {string} - The unique account name. + */ +function generateUniqueAccountName(config) { + // Generate a potential unique name. e.g. "foo@bar.com (POP3)". + let name = `${ + config.identity.emailAddress + } (${config.incoming.type.toUpperCase()})`; + + // If this name already exists, append a counter until we find a unique name. + if (checkAccountNameAlreadyExists(name)) { + let counter = 2; + while (checkAccountNameAlreadyExists(`${name}_${counter}`)) { + counter++; + } + // e.g. "foo@bar.com (POP3)_1". + name = `${name}_${counter}`; + } + + return name; +} + +/** + * Check if there already is a "Local Folders". If not, create it. + * Copied from AccountWizard.js with minor updates. + */ +function verifyLocalFoldersAccount(am) { + let localMailServer; + try { + localMailServer = am.localFoldersServer; + } catch (ex) { + localMailServer = null; + } + + try { + if (!localMailServer) { + // creates a copy of the identity you pass in + am.createLocalMailAccount(); + try { + localMailServer = am.localFoldersServer; + } catch (ex) { + lazy.AccountCreationUtils.ddump( + "Error! we should have found the local mail server " + + "after we created it." + ); + } + } + } catch (ex) { + lazy.AccountCreationUtils.ddump("Error in verifyLocalFoldersAccount " + ex); + } +} + +var CreateInBackend = { + checkIncomingServerAlreadyExists, + checkOutgoingServerAlreadyExists, + createAccountInBackend, +}; diff --git a/comm/mail/components/accountcreation/ExchangeAutoDiscover.jsm b/comm/mail/components/accountcreation/ExchangeAutoDiscover.jsm new file mode 100644 index 0000000000..5813aa0240 --- /dev/null +++ b/comm/mail/components/accountcreation/ExchangeAutoDiscover.jsm @@ -0,0 +1,676 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const EXPORTED_SYMBOLS = ["fetchConfigFromExchange", "getAddonsList"]; + +var { AccountCreationUtils } = ChromeUtils.import( + "resource:///modules/accountcreation/AccountCreationUtils.jsm" +); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + AccountConfig: "resource:///modules/accountcreation/AccountConfig.jsm", + FetchHTTP: "resource:///modules/accountcreation/FetchHTTP.jsm", + GuessConfig: "resource:///modules/accountcreation/GuessConfig.jsm", + Sanitizer: "resource:///modules/accountcreation/Sanitizer.jsm", +}); + +var { + Abortable, + assert, + ddump, + deepCopy, + Exception, + gAccountSetupLogger, + getStringBundle, + PriorityOrderAbortable, + SuccessiveAbortable, + TimeoutAbortable, +} = AccountCreationUtils; + +/** + * Tries to get a configuration from an MS Exchange server + * using Microsoft AutoDiscover protocol. + * + * Disclaimers: + * - To support domain hosters, we cannot use SSL. That means we + * rely on insecure DNS and http, which means the results may be + * forged when under attack. The same is true for guessConfig(), though. + * + * @param {string} domain - The domain part of the user's email address + * @param {string} emailAddress - The user's email address + * @param {string} username - (Optional) The user's login name. + * If null, email address will be used. + * @param {string} password - The user's password for that email address + * @param {Function(domain, okCallback, cancelCallback)} confirmCallback - A + * callback that will be called to confirm redirection to another domain. + * @param {Function(config {AccountConfig})} successCallback - A callback that + * will be called when we could retrieve a configuration. + * The AccountConfig object will be passed in as first parameter. + * @param {Function(ex)} errorCallback - A callback that + * will be called when we could not retrieve a configuration, + * for whatever reason. This is expected (e.g. when there's no config + * for this domain at this location), + * so do not unconditionally show this to the user. + * The first parameter will be an exception object or error string. + */ +function fetchConfigFromExchange( + domain, + emailAddress, + username, + password, + confirmCallback, + successCallback, + errorCallback +) { + assert(typeof successCallback == "function"); + assert(typeof errorCallback == "function"); + if ( + !Services.prefs.getBoolPref( + "mailnews.auto_config.fetchFromExchange.enabled", + true + ) + ) { + errorCallback("Exchange AutoDiscover disabled per user preference"); + return new Abortable(); + } + + // <https://technet.microsoft.com/en-us/library/bb124251(v=exchg.160).aspx#Autodiscover%20services%20in%20Outlook> + // <https://docs.microsoft.com/en-us/previous-versions/office/developer/exchange-server-interoperability-guidance/hh352638(v%3Dexchg.140)>, search for "The Autodiscover service uses one of these four methods" + let url1 = + "https://autodiscover." + + lazy.Sanitizer.hostname(domain) + + "/autodiscover/autodiscover.xml"; + let url2 = + "https://" + + lazy.Sanitizer.hostname(domain) + + "/autodiscover/autodiscover.xml"; + let url3 = + "http://autodiscover." + + lazy.Sanitizer.hostname(domain) + + "/autodiscover/autodiscover.xml"; + let body = `<?xml version="1.0" encoding="utf-8"?> + <Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006"> + <Request> + <EMailAddress>${emailAddress}</EMailAddress> + <AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema> + </Request> + </Autodiscover>`; + let callArgs = { + uploadBody: body, + post: true, + headers: { + // outlook.com needs this exact string, with space and lower case "utf". + // Compare bug 1454325 comment 15. + "Content-Type": "text/xml; charset=utf-8", + }, + username: username || emailAddress, + password, + allowAuthPrompt: false, + }; + let call; + let fetch; + let fetch3; + + let successive = new SuccessiveAbortable(); + let priority = new PriorityOrderAbortable(function (xml, call) { + // success + readAutoDiscoverResponse( + xml, + successive, + emailAddress, + username, + password, + confirmCallback, + config => { + config.subSource = `exchange-from-${call.foundMsg}`; + return detectStandardProtocols(config, domain, successCallback); + }, + errorCallback + ); + }, errorCallback); // all failed + + call = priority.addCall(); + call.foundMsg = "url1"; + fetch = new lazy.FetchHTTP( + url1, + callArgs, + call.successCallback(), + call.errorCallback() + ); + fetch.start(); + call.setAbortable(fetch); + + call = priority.addCall(); + call.foundMsg = "url2"; + fetch = new lazy.FetchHTTP( + url2, + callArgs, + call.successCallback(), + call.errorCallback() + ); + fetch.start(); + call.setAbortable(fetch); + + call = priority.addCall(); + call.foundMsg = "url3"; + let call3ErrorCallback = call.errorCallback(); + // url3 is HTTP (not HTTPS), so suppress password. Even MS spec demands so. + let call3Args = deepCopy(callArgs); + delete call3Args.username; + delete call3Args.password; + fetch3 = new lazy.FetchHTTP(url3, call3Args, call.successCallback(), ex => { + // url3 is an HTTP URL that will redirect to the real one, usually a + // HTTPS URL of the hoster. XMLHttpRequest unfortunately loses the call + // parameters, drops the auth, drops the body, and turns POST into GET, + // which cause the call to fail. For AutoDiscover mechanism to work, + // we need to repeat the call with the correct parameters again. + let redirectURL = fetch3._request.responseURL; + if (!redirectURL.startsWith("https:")) { + call3ErrorCallback(ex); + return; + } + let redirectURI = Services.io.newURI(redirectURL); + let redirectDomain = Services.eTLD.getBaseDomain(redirectURI); + let originalDomain = Services.eTLD.getBaseDomainFromHost(domain); + + function fetchRedirect() { + let fetchCall = priority.addCall(); + let fetch = new lazy.FetchHTTP( + redirectURL, + callArgs, // now with auth + fetchCall.successCallback(), + fetchCall.errorCallback() + ); + fetchCall.setAbortable(fetch); + fetch.start(); + } + + const kSafeDomains = ["office365.com", "outlook.com"]; + if ( + redirectDomain != originalDomain && + !kSafeDomains.includes(redirectDomain) + ) { + // Given that we received the redirect URL from an insecure HTTP call, + // we ask the user whether he trusts the redirect domain. + gAccountSetupLogger.info("AutoDiscover HTTP redirected to other domain"); + let dialogSuccessive = new SuccessiveAbortable(); + // Because the dialog implements Abortable, the dialog will cancel and + // close automatically, if a slow higher priority call returns late. + let dialogCall = priority.addCall(); + dialogCall.setAbortable(dialogSuccessive); + call3ErrorCallback(new Exception("Redirected")); + dialogSuccessive.current = new TimeoutAbortable( + lazy.setTimeout(() => { + dialogSuccessive.current = confirmCallback( + redirectDomain, + () => { + // User agreed. + fetchRedirect(); + // Remove the dialog from the call stack. + dialogCall.errorCallback()(new Exception("Proceed to fetch")); + }, + ex => { + // User rejected, or action cancelled otherwise. + dialogCall.errorCallback()(ex); + } + ); + // Account for a slow server response. + // This will prevent showing the warning message when not necessary. + // The timeout is just for optics. The Abortable ensures that it works. + }, 2000) + ); + } else { + fetchRedirect(); + call3ErrorCallback(new Exception("Redirected")); + } + }); + fetch3.start(); + call.setAbortable(fetch3); + + successive.current = priority; + return successive; +} + +var gLoopCounter = 0; + +/** + * @param {JXON} xml - The Exchange server AutoDiscover response + * @param {Function(config {AccountConfig})} successCallback - @see accountConfig.js + */ +function readAutoDiscoverResponse( + autoDiscoverXML, + successive, + emailAddress, + username, + password, + confirmCallback, + successCallback, + errorCallback +) { + assert(successive instanceof SuccessiveAbortable); + assert(typeof successCallback == "function"); + assert(typeof errorCallback == "function"); + + // redirect to other email address + if ( + "Account" in autoDiscoverXML.Autodiscover.Response && + "RedirectAddr" in autoDiscoverXML.Autodiscover.Response.Account + ) { + // <https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxdscli/49083e77-8dc2-4010-85c6-f40e090f3b17> + let redirectEmailAddress = lazy.Sanitizer.emailAddress( + autoDiscoverXML.Autodiscover.Response.Account.RedirectAddr + ); + let domain = redirectEmailAddress.split("@").pop(); + if (++gLoopCounter > 2) { + throw new Error("Too many redirects in XML response; domain=" + domain); + } + successive.current = fetchConfigFromExchange( + domain, + redirectEmailAddress, + // Per spec, need to authenticate with the original email address, + // not the redirected address (if not already overridden). + username || emailAddress, + password, + confirmCallback, + successCallback, + errorCallback + ); + return; + } + + let config = readAutoDiscoverXML(autoDiscoverXML, username); + if (config.isComplete()) { + successCallback(config); + } else { + errorCallback(new Exception("No valid configs found in AutoDiscover XML")); + } +} + +/* eslint-disable complexity */ +/** + * @param {JXON} xml - The Exchange server AutoDiscover response + * @param {string} username - (Optional) The user's login name + * If null, email address placeholder will be used. + * @returns {AccountConfig} - @see accountConfig.js + * + * @see <https://www.msxfaq.de/exchange/autodiscover/autodiscover_xml.htm> + */ +function readAutoDiscoverXML(autoDiscoverXML, username) { + if ( + typeof autoDiscoverXML != "object" || + !("Autodiscover" in autoDiscoverXML) || + !("Response" in autoDiscoverXML.Autodiscover) || + !("Account" in autoDiscoverXML.Autodiscover.Response) || + !("Protocol" in autoDiscoverXML.Autodiscover.Response.Account) + ) { + let stringBundle = getStringBundle( + "chrome://messenger/locale/accountCreationModel.properties" + ); + throw new Exception( + stringBundle.GetStringFromName("no_autodiscover.error") + ); + } + var xml = autoDiscoverXML.Autodiscover.Response.Account; + + function array_or_undef(value) { + return value === undefined ? [] : value; + } + + var config = new lazy.AccountConfig(); + config.source = lazy.AccountConfig.kSourceExchange; + config.incoming.username = username || "%EMAILADDRESS%"; + config.incoming.socketType = Ci.nsMsgSocketType.SSL; // only https supported + config.incoming.port = 443; + config.incoming.auth = Ci.nsMsgAuthMethod.passwordCleartext; + config.incoming.authAlternatives = [Ci.nsMsgAuthMethod.OAuth2]; + config.outgoing.addThisServer = false; + config.outgoing.useGlobalPreferredServer = true; + + for (let protocolX of array_or_undef(xml.$Protocol)) { + try { + let type = lazy.Sanitizer.enum( + protocolX.Type, + ["WEB", "EXHTTP", "EXCH", "EXPR", "POP3", "IMAP", "SMTP"], + "unknown" + ); + if (type == "WEB") { + let urlsX; + if ("External" in protocolX) { + urlsX = protocolX.External; + } else if ("Internal" in protocolX) { + urlsX = protocolX.Internal; + } + if (urlsX) { + config.incoming.owaURL = lazy.Sanitizer.url(urlsX.OWAUrl.value); + if ( + !config.incoming.ewsURL && + "Protocol" in urlsX && + "ASUrl" in urlsX.Protocol + ) { + config.incoming.ewsURL = lazy.Sanitizer.url(urlsX.Protocol.ASUrl); + } + config.incoming.type = "exchange"; + let parsedURL = new URL(config.incoming.owaURL); + config.incoming.hostname = lazy.Sanitizer.hostname( + parsedURL.hostname + ); + if (parsedURL.port) { + config.incoming.port = lazy.Sanitizer.integer(parsedURL.port); + } + } + } else if (type == "EXHTTP" || type == "EXCH") { + config.incoming.ewsURL = lazy.Sanitizer.url(protocolX.EwsUrl); + if (!config.incoming.ewsURL) { + config.incoming.ewsURL = lazy.Sanitizer.url(protocolX.ASUrl); + } + config.incoming.type = "exchange"; + let parsedURL = new URL(config.incoming.ewsURL); + config.incoming.hostname = lazy.Sanitizer.hostname(parsedURL.hostname); + if (parsedURL.port) { + config.incoming.port = lazy.Sanitizer.integer(parsedURL.port); + } + } else if (type == "POP3" || type == "IMAP" || type == "SMTP") { + let server; + if (type == "SMTP") { + server = config.createNewOutgoing(); + } else { + server = config.createNewIncoming(); + } + + server.type = lazy.Sanitizer.translate(type, { + POP3: "pop3", + IMAP: "imap", + SMTP: "smtp", + }); + server.hostname = lazy.Sanitizer.hostname(protocolX.Server); + server.port = lazy.Sanitizer.integer(protocolX.Port); + server.socketType = Ci.nsMsgSocketType.plain; + if ( + "SSL" in protocolX && + protocolX.SSL.toLowerCase() == "on" // "On" or "Off" + ) { + // SSL is too unspecific. Do they mean STARTTLS or normal TLS? + // For now, assume normal TLS, unless it's a standard plain port. + switch (server.port) { + case 143: // IMAP standard + case 110: // POP3 standard + case 25: // SMTP standard + case 587: // SMTP standard + server.socketType = Ci.nsMsgSocketType.alwaysSTARTTLS; + break; + case 993: // IMAP SSL + case 995: // POP3 SSL + case 465: // SMTP SSL + default: + // if non-standard port, assume normal TLS, not STARTTLS + server.socketType = Ci.nsMsgSocketType.SSL; + break; + } + } + server.auth = Ci.nsMsgAuthMethod.passwordCleartext; + if ( + "SPA" in protocolX && + protocolX.SPA.toLowerCase() == "on" // "On" or "Off" + ) { + // Secure Password Authentication = NTLM or GSSAPI/Kerberos + server.auth = Ci.nsMsgAuthMethod.secure; + } + if ("LoginName" in protocolX) { + server.username = lazy.Sanitizer.nonemptystring(protocolX.LoginName); + } else { + server.username = username || "%EMAILADDRESS%"; + } + + if (type == "SMTP") { + if (!config.outgoing.hostname) { + config.outgoing = server; + } else { + config.outgoingAlternatives.push(server); + } + } else if (!config.incoming.hostname) { + // eslint-disable-line no-lonely-if + config.incoming = server; + } else { + config.incomingAlternatives.push(server); + } + } + + // else unknown or unsupported protocol + } catch (e) { + console.error(e); + } + } + + // OAuth2 settings, so that createInBackend() doesn't bail out + if (config.incoming.owaURL || config.incoming.ewsURL) { + config.incoming.oauthSettings = { + issuer: config.incoming.hostname, + scope: config.incoming.owaURL || config.incoming.ewsURL, + }; + config.outgoing.oauthSettings = { + issuer: config.incoming.hostname, + scope: config.incoming.owaURL || config.incoming.ewsURL, + }; + } + + return config; +} +/* eslint-enable complexity */ + +/** + * Ask server which addons can handle this config. + * + * @param {AccountConfig} config + * @param {Function(config {AccountConfig})} successCallback + * @returns {Abortable} + */ +function getAddonsList(config, successCallback, errorCallback) { + let incoming = [config.incoming, ...config.incomingAlternatives].find( + alt => alt.type == "exchange" + ); + if (!incoming) { + successCallback(); + return new Abortable(); + } + let url = Services.prefs.getCharPref("mailnews.auto_config.addons_url"); + if (!url) { + errorCallback(new Exception("no URL for addons list configured")); + return new Abortable(); + } + let fetch = new lazy.FetchHTTP( + url, + { allowCache: true, timeout: 10000 }, + function (json) { + let addons = readAddonsJSON(json); + addons = addons.filter(addon => { + // Find types matching the current config. + // Pick the first in the list as the preferred one and + // tell the UI to use that one. + addon.useType = addon.supportedTypes.find( + type => + (incoming.owaURL && type.protocolType == "owa") || + (incoming.ewsURL && type.protocolType == "ews") || + (incoming.easURL && type.protocolType == "eas") + ); + return !!addon.useType; + }); + if (addons.length == 0) { + errorCallback( + new Exception( + "Config found, but no addons known to handle the config" + ) + ); + return; + } + config.addons = addons; + successCallback(config); + }, + errorCallback + ); + fetch.start(); + return fetch; +} + +/** + * This reads the addons list JSON and makes security validations, + * e.g. that the URLs are not chrome: URLs, which could lead to exploits. + * It also chooses the right language etc.. + * + * @param {JSON} json - the addons.json file contents + * @returns {Array of AddonInfo} - @see AccountConfig.addons + * + * accountTypes are listed in order of decreasing preference. + * Languages are 2-letter codes. If a language is not available, + * the first name or description will be used. + * + * Parse e.g. +[ + { + "id": "owl@beonex.com", + "name": { + "en": "Owl", + "de": "Eule" + }, + "description": { + "en": "Owl is a paid third-party addon that allows you to access your email account on Exchange servers. See the website for prices.", + "de": "Eule ist eine Erweiterung von einem Drittanbieter, die Ihnen erlaubt, Exchange-Server zu benutzen. Sie ist kostenpflichtig. Die Preise finden Sie auf der Website." + }, + "minVersion": "0.2", + "xpiURL": "http://www.beonex.com/owl/latest.xpi", + "websiteURL": "http://www.beonex.com/owl/", + "icon32": "http://www.beonex.com/owl/owl-32.png", + "accountTypes": [ + { + "generalType": "exchange", + "protocolType": "owa", + "addonAccountType": "owl-owa" + }, + { + "generalType": "exchange", + "protocolType": "eas", + "addonAccountType": "owl-eas" + } + ] + } +] + */ +function readAddonsJSON(json) { + let addons = []; + function ensureArray(value) { + return Array.isArray(value) ? value : []; + } + let xulLocale = Services.locale.requestedLocale; + let locale = xulLocale ? xulLocale.substring(0, 5) : "default"; + for (let addonJSON of ensureArray(json)) { + try { + let addon = { + id: addonJSON.id, + minVersion: addonJSON.minVersion, + xpiURL: lazy.Sanitizer.url(addonJSON.xpiURL), + websiteURL: lazy.Sanitizer.url(addonJSON.websiteURL), + icon32: addonJSON.icon32 ? lazy.Sanitizer.url(addonJSON.icon32) : null, + supportedTypes: [], + }; + assert( + new URL(addon.xpiURL).protocol == "https:", + "XPI download URL needs to be https" + ); + addon.name = + locale in addonJSON.name ? addonJSON.name[locale] : addonJSON.name[0]; + addon.description = + locale in addonJSON.description + ? addonJSON.description[locale] + : addonJSON.description[0]; + for (let typeJSON of ensureArray(addonJSON.accountTypes)) { + try { + addon.supportedTypes.push({ + generalType: lazy.Sanitizer.alphanumdash(typeJSON.generalType), + protocolType: lazy.Sanitizer.alphanumdash(typeJSON.protocolType), + addonAccountType: lazy.Sanitizer.alphanumdash( + typeJSON.addonAccountType + ), + }); + } catch (e) { + ddump(e); + } + } + addons.push(addon); + } catch (e) { + ddump(e); + } + } + return addons; +} + +/** + * Probe a found Exchange server for IMAP/POP3 and SMTP support. + * + * @param {AccountConfig} config - The initial detected Exchange configuration. + * @param {string} domain - The domain part of the user's email address + * @param {Function(config {AccountConfig})} successCallback - A callback that + * will be called when we found an appropriate configuration. + * The AccountConfig object will be passed in as first parameter. + */ +function detectStandardProtocols(config, domain, successCallback) { + gAccountSetupLogger.info("Exchange Autodiscover gave some results."); + let alts = [config.incoming, ...config.incomingAlternatives]; + if (alts.find(alt => alt.type == "imap" || alt.type == "pop3")) { + // Autodiscover found an exchange server with advertized IMAP and/or + // POP3 support. We're done then. + config.preferStandardProtocols(); + successCallback(config); + return; + } + + // Autodiscover is known not to advertise all that it supports. Let's see + // if there really isn't any IMAP/POP3 support by probing the Exchange + // server. Use the server hostname already found. + let config2 = new lazy.AccountConfig(); + config2.incoming.hostname = config.incoming.hostname; + config2.incoming.username = config.incoming.username || "%EMAILADDRESS%"; + // For Exchange 2013+ Kerberos/GSSAPI and NTLM options do not work by + // default at least for Linux users, even if support is detected. + config2.incoming.auth = Ci.nsMsgAuthMethod.passwordCleartext; + + config2.outgoing.hostname = config.incoming.hostname; + config2.outgoing.username = config.incoming.username || "%EMAILADDRESS%"; + + config2.incomingAlternatives = config.incomingAlternatives; + config2.incomingAlternatives.push(config.incoming); // type=exchange + + config2.outgoingAlternatives = config.outgoingAlternatives; + if (config.outgoing.hostname) { + config2.outgoingAlternatives.push(config.outgoing); + } + + lazy.GuessConfig.guessConfig( + domain, + function (type, hostname, port, ssl, done, config) { + gAccountSetupLogger.info( + `Probing exchange server ${hostname} for ${type} protocol support.` + ); + }, + function (probedConfig) { + // Probing succeeded: found open protocols, yay! + successCallback(probedConfig); + }, + function (e, probedConfig) { + // Probing didn't find any open protocols. + // Let's use the exchange (only) config that was listed then. + config.subSource += "-guess"; + successCallback(config); + }, + config2, + "both" + ); +} diff --git a/comm/mail/components/accountcreation/FetchConfig.jsm b/comm/mail/components/accountcreation/FetchConfig.jsm new file mode 100644 index 0000000000..1bf16ca2ed --- /dev/null +++ b/comm/mail/components/accountcreation/FetchConfig.jsm @@ -0,0 +1,299 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const EXPORTED_SYMBOLS = ["FetchConfig"]; + +const { AccountCreationUtils } = ChromeUtils.import( + "resource:///modules/accountcreation/AccountCreationUtils.jsm" +); +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "FetchHTTP", + "resource:///modules/accountcreation/FetchHTTP.jsm" +); +ChromeUtils.defineModuleGetter( + lazy, + "readFromXML", + "resource:///modules/accountcreation/readFromXML.jsm" +); +ChromeUtils.defineModuleGetter( + lazy, + "Sanitizer", + "resource:///modules/accountcreation/Sanitizer.jsm" +); + +const { DNS } = ChromeUtils.import("resource:///modules/DNS.jsm"); +const { JXON } = ChromeUtils.import("resource:///modules/JXON.jsm"); + +const { + Abortable, + ddump, + Exception, + PriorityOrderAbortable, + PromiseAbortable, + readURLasUTF8, + runAsync, + SuccessiveAbortable, + TimeoutAbortable, +} = AccountCreationUtils; + +/** + * Tries to find a configuration for this ISP on the local harddisk, in the + * application install directory's "isp" subdirectory. + * Params @see fetchConfigFromISP() + */ +function fetchConfigFromDisk(domain, successCallback, errorCallback) { + return new TimeoutAbortable( + runAsync(function () { + try { + // <TB installdir>/isp/example.com.xml + var configLocation = Services.dirsvc.get("CurProcD", Ci.nsIFile); + configLocation.append("isp"); + configLocation.append(lazy.Sanitizer.hostname(domain) + ".xml"); + + if (!configLocation.exists() || !configLocation.isReadable()) { + errorCallback(new Exception("local file not found")); + return; + } + var contents = readURLasUTF8(Services.io.newFileURI(configLocation)); + let domParser = new DOMParser(); + const xml = JXON.build(domParser.parseFromString(contents, "text/xml")); + successCallback(lazy.readFromXML(xml, "disk")); + } catch (e) { + errorCallback(e); + } + }) + ); +} + +/** + * Tries to get a configuration from the ISP / mail provider directly. + * + * Disclaimers: + * - To support domain hosters, we cannot use SSL. That means we + * rely on insecure DNS and http, which means the results may be + * forged when under attack. The same is true for guessConfig(), though. + * + * @param domain {String} - The domain part of the user's email address + * @param emailAddress {String} - The user's email address + * @param successCallback {Function(config {AccountConfig}})} A callback that + * will be called when we could retrieve a configuration. + * The AccountConfig object will be passed in as first parameter. + * @param errorCallback {Function(ex)} - A callback that + * will be called when we could not retrieve a configuration, + * for whatever reason. This is expected (e.g. when there's no config + * for this domain at this location), + * so do not unconditionally show this to the user. + * The first parameter will be an exception object or error string. + */ +function fetchConfigFromISP( + domain, + emailAddress, + successCallback, + errorCallback +) { + if ( + !Services.prefs.getBoolPref("mailnews.auto_config.fetchFromISP.enabled") + ) { + errorCallback(new Exception("ISP fetch disabled per user preference")); + return new Abortable(); + } + + let conf1 = + "autoconfig." + lazy.Sanitizer.hostname(domain) + "/mail/config-v1.1.xml"; + // .well-known/ <http://tools.ietf.org/html/draft-nottingham-site-meta-04> + let conf2 = + lazy.Sanitizer.hostname(domain) + + "/.well-known/autoconfig/mail/config-v1.1.xml"; + // This list is sorted by decreasing priority + var urls = ["https://" + conf1, "https://" + conf2]; + if ( + !Services.prefs.getBoolPref("mailnews.auto_config.fetchFromISP.sslOnly") + ) { + urls.push("http://" + conf1, "http://" + conf2); + } + let callArgs = { + urlArgs: { + emailaddress: emailAddress, + }, + }; + if ( + !Services.prefs.getBoolPref( + "mailnews.auto_config.fetchFromISP.sendEmailAddress" + ) + ) { + delete callArgs.urlArgs.emailaddress; + } + let call; + let fetch; + + let priority = new PriorityOrderAbortable( + (xml, call) => + successCallback(lazy.readFromXML(xml, `isp-${call.foundMsg}`)), + errorCallback + ); + for (let url of urls) { + call = priority.addCall(); + call.foundMsg = url.startsWith("https") ? "https" : "http"; + fetch = new lazy.FetchHTTP( + url, + callArgs, + call.successCallback(), + call.errorCallback() + ); + call.setAbortable(fetch); + fetch.start(); + } + + return priority; +} + +/** + * Tries to get a configuration for this ISP from a central database at + * Mozilla servers. + * Params @see fetchConfigFromISP() + */ +function fetchConfigFromDB(domain, successCallback, errorCallback) { + let url = Services.prefs.getCharPref("mailnews.auto_config_url"); + if (!url) { + errorCallback(new Exception("no URL for ISP DB configured")); + return new Abortable(); + } + domain = lazy.Sanitizer.hostname(domain); + + // If we don't specify a place to put the domain, put it at the end. + if (!url.includes("{{domain}}")) { + url = url + domain; + } else { + url = url.replace("{{domain}}", domain); + } + + let fetch = new lazy.FetchHTTP( + url, + { timeout: 10000 }, // 10 seconds + function (result) { + successCallback(lazy.readFromXML(result, "db")); + }, + errorCallback + ); + fetch.start(); + return fetch; +} + +/** + * Does a lookup of DNS MX, to get the server that is responsible for + * receiving mail for this domain. Then it takes the domain of that + * server, and does another lookup (in ISPDB and possibly at ISP autoconfig + * server) and if such a config is found, returns that. + * + * Disclaimers: + * - DNS is unprotected, meaning the results could be forged. + * The same is true for fetchConfigFromISP() and guessConfig(), though. + * - DNS MX tells us the incoming server, not the mailbox (IMAP) server. + * They are different. This mechanism is only an approximation + * for hosted domains (yourname.com is served by mx.hoster.com and + * therefore imap.hoster.com - that "therefore" is exactly the + * conclusional jump we make here.) and alternative domains + * (e.g. yahoo.de -> yahoo.com). + * - We make a look up for the base domain. E.g. if MX is + * mx1.incoming.servers.hoster.com, we look up hoster.com. + * Thanks to Services.eTLD, we also get bbc.co.uk right. + * + * Params @see fetchConfigFromISP() + */ +function fetchConfigForMX(domain, successCallback, errorCallback) { + const sanitizedDomain = lazy.Sanitizer.hostname(domain); + const sucAbortable = new SuccessiveAbortable(); + const time = Date.now(); + + sucAbortable.current = getMX( + sanitizedDomain, + function (mxHostname) { + // success + ddump("getmx took " + (Date.now() - time) + "ms"); + let sld = Services.eTLD.getBaseDomainFromHost(mxHostname); + ddump("base domain " + sld + " for " + mxHostname); + if (sld == sanitizedDomain) { + errorCallback( + new Exception("MX lookup would be no different from domain") + ); + return; + } + + // In addition to just the base domain, also check the full domain of the MX server + // to differentiate between Outlook.com/Hotmail and Office365 business domains. + let mxDomain; + try { + mxDomain = Services.eTLD.getNextSubDomain(mxHostname); + } catch (ex) { + // e.g. hostname doesn't have enough components + console.error(ex); // not fatal + } + let priority = new PriorityOrderAbortable(successCallback, errorCallback); + if (mxDomain && sld != mxDomain) { + let call = priority.addCall(); + let fetch = fetchConfigFromDB( + mxDomain, + call.successCallback(), + call.errorCallback() + ); + call.setAbortable(fetch); + } + let call = priority.addCall(); + let fetch = fetchConfigFromDB( + sld, + call.successCallback(), + call.errorCallback() + ); + call.setAbortable(fetch); + sucAbortable.current = priority; + }, + errorCallback + ); + return sucAbortable; +} + +/** + * Queries the DNS MX records for a given domain. Calls `successCallback` with + * the hostname of the MX server. If there are several entries with different + * preference values, only the most preferred (i.e. has the lowest value) + * is used. If there are several most preferred servers (i.e. round robin), + * only one of them is used. + * + * @param {string} sanitizedDomain @see fetchConfigFromISP() + * @param {function(hostname {string})} - successCallback + * Called when we found an MX for the domain. + * For |hostname|, see description above. + * @param {function({Exception|string})} errorCallback @see fetchConfigFromISP() + */ +function getMX(sanitizedDomain, successCallback, errorCallback) { + return new PromiseAbortable( + DNS.mx(sanitizedDomain), + function (records) { + const filteredRecs = records.filter(record => record.host); + + if (filteredRecs.length > 0) { + const sortedRecs = filteredRecs.sort((a, b) => a.prio > b.prio); + const firstHost = sortedRecs[0].host; + successCallback(firstHost); + } else { + errorCallback( + new Exception( + "No hostname found in MX records for sanitizedDomain=" + + sanitizedDomain + ) + ); + } + }, + errorCallback + ); +} + +var FetchConfig = { + forMX: fetchConfigForMX, + fromDB: fetchConfigFromDB, + fromISP: fetchConfigFromISP, + fromDisk: fetchConfigFromDisk, +}; diff --git a/comm/mail/components/accountcreation/FetchHTTP.jsm b/comm/mail/components/accountcreation/FetchHTTP.jsm new file mode 100644 index 0000000000..54b3629906 --- /dev/null +++ b/comm/mail/components/accountcreation/FetchHTTP.jsm @@ -0,0 +1,401 @@ +/* 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/. */ + +/** + * This is a small wrapper around XMLHttpRequest, which solves various + * inadequacies of the API, e.g. error handling. It is entirely generic and + * can be used for purposes outside of even mail. + * + * It does not provide download progress, but assumes that the + * fetched resource is so small (<1 10 KB) that the roundtrip and + * response generation is far more significant than the + * download time of the response. In other words, it's fine for RPC, + * but not for bigger file downloads. + */ + +const EXPORTED_SYMBOLS = ["FetchHTTP"]; + +const { AccountCreationUtils } = ChromeUtils.import( + "resource:///modules/accountcreation/AccountCreationUtils.jsm" +); +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "Sanitizer", + "resource:///modules/accountcreation/Sanitizer.jsm" +); + +const { JXON } = ChromeUtils.import("resource:///modules/JXON.jsm"); + +const { + Abortable, + alertPrompt, + assert, + ddump, + Exception, + gAccountSetupLogger, + getStringBundle, + UserCancelledException, +} = AccountCreationUtils; + +/** + * Set up a fetch. + * + * @param {string} url - URL of the server function. + * ATTENTION: The caller needs to make sure that the URL is secure to call. + * @param {object} args - Additional parameters as properties, see below + * + * @param {Function({string} result)} successCallback + * Called when the server call worked (no errors). + * |result| will contain the body of the HTTP response, as string. + * @param {Function(ex)} errorCallback + * Called in case of error. ex contains the error + * with a user-displayable but not localized |.message| and maybe a + * |.code|, which can be either + * - an nsresult error code, + * - an HTTP result error code (0...1000) or + * - negative: 0...-100 : + * -2 = can't resolve server in DNS etc. + * -4 = response body (e.g. XML) malformed + * + * The following optional parameters are supported as properties of the |args| object: + * + * @param {Object, associative array} urlArgs - Parameters to add + * to the end of the URL as query string. E.g. + * { foo: "bla", bar: "blub blub" } will add "?foo=bla&bar=blub%20blub" + * to the URL + * (unless the URL already has a "?", then it adds "&foo..."). + * The values will be urlComponentEncoded, so pass them unencoded. + * @param {Object, associative array} headers - HTTP headers to be added + * to the HTTP request. + * { foo: "blub blub" } will add HTTP header "Foo: Blub blub". + * The values will be passed verbatim. + * @param {boolean} post - HTTP GET or POST + * Only influences the HTTP request method, + * i.e. first line of the HTTP request, not the body or parameters. + * Use POST when you modify server state, + * GET when you only request information. + * Default is GET. + * @param {Object, associative array} bodyFormArgs - Like urlArgs, + * just that the params will be sent x-url-encoded in the body, + * like a HTML form post. + * The values will be urlComponentEncoded, so pass them unencoded. + * This cannot be used together with |uploadBody|. + * @param {object} uploadBody - Arbitrary object, which to use as + * body of the HTTP request. Will also set the mimetype accordingly. + * Only supported object types, currently only JXON is supported + * (sending XML). + * Usually, you have nothing to upload, so just pass |null|. + * Only supported object types, currently supported: + * JXON -> sending XML + * JS object -> sending JSON + * string -> sending text/plain + * If you want to override the body mimetype, set header Content-Type below. + * Usually, you have nothing to upload, so just leave it at |null|. + * Default |null|. + * @param {boolean} allowCache (default true) + * @param {string} username (default null = no authentication) + * @param {string} password (default null = no authentication) + * @param {boolean} allowAuthPrompt (default true) + * @param {boolean} requireSecureAuth (default false) + * Ignore the username and password unless we are using https: + * This also applies to both https: to http: and http: to https: redirects. + */ +function FetchHTTP(url, args, successCallback, errorCallback) { + assert(typeof successCallback == "function", "BUG: successCallback"); + assert(typeof errorCallback == "function", "BUG: errorCallback"); + this._url = lazy.Sanitizer.string(url); + if (!args) { + args = {}; + } + if (!args.urlArgs) { + args.urlArgs = {}; + } + if (!args.headers) { + args.headers = {}; + } + + this._args = args; + this._args.post = lazy.Sanitizer.boolean(args.post || false); // default false + this._args.allowCache = + "allowCache" in args ? lazy.Sanitizer.boolean(args.allowCache) : true; // default true + this._args.allowAuthPrompt = lazy.Sanitizer.boolean( + args.allowAuthPrompt || false + ); // default false + this._args.requireSecureAuth = lazy.Sanitizer.boolean( + args.requireSecureAuth || false + ); // default false + this._args.timeout = lazy.Sanitizer.integer(args.timeout || 5000); // default 5 seconds + this._successCallback = successCallback; + this._errorCallback = errorCallback; + this._logger = gAccountSetupLogger; + this._logger.info("Requesting <" + url + ">"); +} +FetchHTTP.prototype = { + __proto__: Abortable.prototype, + _url: null, // URL as passed to ctor, without arguments + _args: null, + _successCallback: null, + _errorCallback: null, + _request: null, // the XMLHttpRequest object + result: null, + + start() { + let url = this._url; + for (let name in this._args.urlArgs) { + url += + (!url.includes("?") ? "?" : "&") + + name + + "=" + + encodeURIComponent(this._args.urlArgs[name]); + } + this._request = new XMLHttpRequest(); + let request = this._request; + request.mozBackgroundRequest = !this._args.allowAuthPrompt; + let username = null, + password = null; + if (url.startsWith("https:") || !this._args.requireSecureAuth) { + username = this._args.username; + password = this._args.password; + } + request.open( + this._args.post ? "POST" : "GET", + url, + true, + username, + password + ); + request.channel.loadGroup = null; + request.timeout = this._args.timeout; + // needs bug 407190 patch v4 (or higher) - uncomment if that lands. + // try { + // var channel = request.channel.QueryInterface(Ci.nsIHttpChannel2); + // channel.connectTimeout = 5; + // channel.requestTimeout = 5; + // } catch (e) { dump(e + "\n"); } + + if (!this._args.allowCache) { + // Disable Mozilla HTTP cache + request.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE; + } + + // body + let mimetype = null; + let body = this._args.uploadBody; + if (typeof body == "object" && "nodeType" in body) { + // XML + mimetype = "text/xml; charset=UTF-8"; + body = new XMLSerializer().serializeToString(body); + } else if (typeof body == "object") { + // JSON + mimetype = "text/json; charset=UTF-8"; + body = JSON.stringify(body); + } else if (typeof body == "string") { + // Plaintext + // You can override the mimetype with { headers: {"Content-Type" : "text/foo" } } + mimetype = "text/plain; charset=UTF-8"; + // body already set above + } else if (this._args.bodyFormArgs) { + mimetype = "application/x-www-form-urlencoded; charset=UTF-8"; + body = ""; + for (let name in this._args.bodyFormArgs) { + body += + (body ? "&" : "") + + name + + "=" + + encodeURIComponent(this._args.bodyFormArgs[name]); + } + } + + // Headers + if (mimetype && !("Content-Type" in this._args.headers)) { + request.setRequestHeader("Content-Type", mimetype); + } + if (username && password) { + // workaround, because open(..., username, password) does not work. + request.setRequestHeader( + "Authorization", + "Basic " + + btoa( + // btoa() takes a BinaryString. + String.fromCharCode( + ...new TextEncoder().encode(username + ":" + password) + ) + ) + ); + } + for (let name in this._args.headers) { + request.setRequestHeader(name, this._args.headers[name]); + if (name == "Cookie") { + // Websites are not allowed to set this, but chrome is. + // Nevertheless, the cookie lib later overwrites our header. + // request.channel.setCookie(this._args.headers[name]); -- crashes + // So, deactivate that Firefox cookie lib. + request.channel.loadFlags |= Ci.nsIRequest.LOAD_ANONYMOUS; + } + } + + var me = this; + request.onload = function () { + me._response(true); + }; + request.onerror = function () { + me._response(false); + }; + request.ontimeout = function () { + me._response(false); + }; + request.send(body); + // Store the original stack so we can use it if there is an exception + this._callStack = Error().stack; + }, + _response(success, exStored) { + try { + var errorCode = null; + var errorStr = null; + + if ( + success && + this._request.status >= 200 && + this._request.status < 300 + ) { + // HTTP level success + try { + // response + var mimetype = this._request.getResponseHeader("Content-Type"); + if (!mimetype) { + mimetype = ""; + } + mimetype = mimetype.split(";")[0]; + if ( + mimetype == "text/xml" || + mimetype == "application/xml" || + mimetype == "text/rdf" + ) { + // XML + this.result = JXON.build(this._request.responseXML); + } else if ( + mimetype == "text/json" || + mimetype == "application/json" + ) { + // JSON + this.result = JSON.parse(this._request.responseText); + } else { + // Plaintext (fallback) + // ddump("mimetype: " + mimetype + " only supported as text"); + this.result = this._request.responseText; + } + } catch (e) { + success = false; + errorStr = getStringBundle( + "chrome://messenger/locale/accountCreationUtil.properties" + ).GetStringFromName("bad_response_content.error"); + errorCode = -4; + } + } else if ( + this._args.username && + this._request.responseURL.replace(/\/\/.*@/, "//") != this._url && + this._request.responseURL.startsWith( + this._args.requireSecureAuth ? "https" : "http" + ) && + !this._isRetry + ) { + // Redirects lack auth, see <https://stackoverflow.com/a/28411170> + this._logger.info( + "Call to <" + + this._url + + "> was redirected to <" + + this._request.responseURL + + ">, and failed. Re-trying the new URL with authentication again." + ); + this._url = this._request.responseURL; + this._isRetry = true; + this.start(); + return; + } else { + success = false; + try { + errorCode = this._request.status; + errorStr = this._request.statusText; + } catch (e) { + // In case .statusText throws (it's marked as [Throws] in the webidl), + // continue with empty errorStr. + } + if (!errorStr) { + // If we can't resolve the hostname in DNS etc., .statusText is empty. + errorCode = -2; + errorStr = getStringBundle( + "chrome://messenger/locale/accountCreationUtil.properties" + ).GetStringFromName("cannot_contact_server.error"); + ddump(errorStr + " on <" + this._url + ">"); + } + } + + // Callbacks + if (success) { + try { + this._successCallback(this.result); + } catch (e) { + e.stack = this._callStack; + this._error(e); + } + } else if (exStored) { + this._error(exStored); + } else { + // Put the caller's stack into the exception + let e = new ServerException(errorStr, errorCode, this._url); + e.stack = this._callStack; + this._error(e); + } + + if (this._finishedCallback) { + try { + this._finishedCallback(this); + } catch (e) { + console.error(e); + } + } + } catch (e) { + // error in our fetchhttp._response() code + this._error(e); + } + }, + _error(e) { + try { + this._errorCallback(e); + } catch (e) { + // error in errorCallback, too! + console.error(e); + alertPrompt("Error in errorCallback for fetchhttp", e); + } + }, + /** + * Call this between start() and finishedCallback fired. + */ + cancel(ex) { + assert(!this.result, "Call already returned"); + + this._request.abort(); + + // Need to manually call error handler + // <https://bugzilla.mozilla.org/show_bug.cgi?id=218236#c11> + this._response(false, ex ? ex : new UserCancelledException()); + }, + /** + * Allows caller or lib to be notified when the call is done. + * This is useful to enable and disable a Cancel button in the UI, + * which allows to cancel the network request. + */ + setFinishedCallback(finishedCallback) { + this._finishedCallback = finishedCallback; + }, +}; + +function ServerException(msg, code, uri) { + Exception.call(this, msg); + this.code = code; + this.uri = uri; +} +ServerException.prototype = Object.create(Exception.prototype); +ServerException.prototype.constructor = ServerException; diff --git a/comm/mail/components/accountcreation/GuessConfig.jsm b/comm/mail/components/accountcreation/GuessConfig.jsm new file mode 100644 index 0000000000..3d590311d9 --- /dev/null +++ b/comm/mail/components/accountcreation/GuessConfig.jsm @@ -0,0 +1,1317 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const EXPORTED_SYMBOLS = ["GuessConfig"]; + +const { AccountCreationUtils } = ChromeUtils.import( + "resource:///modules/accountcreation/AccountCreationUtils.jsm" +); +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "AccountConfig", + "resource:///modules/accountcreation/AccountConfig.jsm" +); +ChromeUtils.defineModuleGetter( + lazy, + "Sanitizer", + "resource:///modules/accountcreation/Sanitizer.jsm" +); + +const { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); + +const { + Abortable, + alertPrompt, + assert, + CancelledException, + ddump, + deepCopy, + Exception, + gAccountSetupLogger, + getStringBundle, + NotReached, + UserCancelledException, +} = AccountCreationUtils; + +/** + * Try to guess the config, by: + * - guessing hostnames (pop3.<domain>, pop.<domain>, imap.<domain>, + * mail.<domain> etc.) + * - probing known ports (for IMAP, POP3 etc., with SSL, STARTTLS etc.) + * - opening a connection via the right protocol and checking the + * protocol-specific CAPABILITIES like that the server returns. + * + * Final verification is not done here, but in verifyConfig(). + * + * This function is async. + * + * @param domain {String} the domain part of the email address + * @param progressCallback {function(type, hostname, port, socketType, done)} + * Called when we try a new hostname/port. + * type {String-enum} @see AccountConfig type - "imap", "pop3", "smtp" + * hostname {String} + * port {Integer} + * socketType {nsMsgSocketType} @see MailNewsTypes2.idl + * 0 = plain, 2 = STARTTLS, 3 = SSL + * done {Boolean} false, if we start probing this host/port, true if we're + * done and the host is good. (there is no notification when a host is + * bad, we'll just tell about the next host tried) + * @param successCallback {function(config {AccountConfig})} + * Called when we could guess the config. + * param accountConfig {AccountConfig} The guessed account config. + * username, password, realname, emailaddress etc. are not filled out, + * but placeholders to be filled out via replaceVariables(). + * @param errorCallback function(ex) + * Called when we could guess not the config, either + * because we have not found anything or + * because there was an error (e.g. no network connection). + * The ex.message will contain a user-presentable message. + * @param resultConfig {AccountConfig} (optional) + * A config which may be partially filled in. If so, it will be used as base + * for the guess. + * @param which {String-enum} (optional) "incoming", "outgoing", or "both". + * Default "both". Whether to guess only the incoming or outgoing server. + * @result {Abortable} Allows you to cancel the guess + */ +function guessConfig( + domain, + progressCallback, + successCallback, + errorCallback, + resultConfig, + which +) { + assert(typeof progressCallback == "function", "need progressCallback"); + assert(typeof successCallback == "function", "need successCallback"); + assert(typeof errorCallback == "function", "need errorCallback"); + + // Servers that we know enough that they support OAuth2 do not need guessing. + if (resultConfig.incoming.auth == Ci.nsMsgAuthMethod.OAuth2) { + successCallback(resultConfig); + return new Abortable(); + } + + if (!resultConfig) { + resultConfig = new lazy.AccountConfig(); + } + resultConfig.source = lazy.AccountConfig.kSourceGuess; + + if (!which) { + which = "both"; + } + + if (!Services.prefs.getBoolPref("mailnews.auto_config.guess.enabled")) { + errorCallback("Guessing config disabled per user preference"); + return new Abortable(); + } + + var incomingHostDetector = null; + var outgoingHostDetector = null; + var incomingEx = null; // if incoming had error, store ex here + var outgoingEx = null; // if incoming had error, store ex here + var incomingDone = which == "outgoing"; + var outgoingDone = which == "incoming"; + // If we're offline, we're going to pick the most common settings. + // (Not the "best" settings, but common). + if (Services.io.offline) { + // TODO: don't do this. Bug 599173. + resultConfig.source = lazy.AccountConfig.kSourceUser; + resultConfig.incoming.hostname = "mail." + domain; + resultConfig.incoming.username = resultConfig.identity.emailAddress; + resultConfig.outgoing.username = resultConfig.identity.emailAddress; + resultConfig.incoming.type = "imap"; + resultConfig.incoming.port = 143; + resultConfig.incoming.socketType = Ci.nsMsgSocketType.alwaysSTARTTLS; + resultConfig.incoming.auth = Ci.nsMsgAuthMethod.passwordCleartext; + resultConfig.outgoing.hostname = "smtp." + domain; + resultConfig.outgoing.socketType = Ci.nsMsgSocketType.alwaysSTARTTLS; + resultConfig.outgoing.port = 587; + resultConfig.outgoing.auth = Ci.nsMsgAuthMethod.passwordCleartext; + resultConfig.incomingAlternatives.push({ + hostname: "mail." + domain, + username: resultConfig.identity.emailAddress, + type: "pop3", + port: 110, + socketType: Ci.nsMsgSocketType.alwaysSTARTTLS, + auth: Ci.nsMsgAuthMethod.passwordCleartext, + }); + successCallback(resultConfig); + return new Abortable(); + } + var progress = function (thisTry) { + progressCallback( + protocolToString(thisTry.protocol), + thisTry.hostname, + thisTry.port, + thisTry.socketType, + false, + resultConfig + ); + }; + + var checkDone = function () { + if (incomingEx) { + try { + errorCallback(incomingEx, resultConfig); + } catch (e) { + console.error(e); + alertPrompt("Error in errorCallback for guessConfig()", e); + } + return; + } + if (outgoingEx) { + try { + errorCallback(outgoingEx, resultConfig); + } catch (e) { + console.error(e); + alertPrompt("Error in errorCallback for guessConfig()", e); + } + return; + } + if (incomingDone && outgoingDone) { + try { + successCallback(resultConfig); + } catch (e) { + try { + errorCallback(e); + } catch (e) { + console.error(e); + alertPrompt("Error in errorCallback for guessConfig()", e); + } + } + } + }; + + var logger = gAccountSetupLogger; + var HostTryToAccountServer = function (thisTry, server) { + server.type = protocolToString(thisTry.protocol); + server.hostname = thisTry.hostname; + server.port = thisTry.port; + server.socketType = thisTry.socketType; + server.auth = + thisTry.authMethod || chooseBestAuthMethod(thisTry.authMethods); + server.authAlternatives = thisTry.authMethods; + // TODO + // cert is also bad when targetSite is set. (Same below for incoming.) + // Fix SSLErrorHandler and security warning dialog in accountSetup.js. + server.badCert = thisTry.selfSignedCert; + server.targetSite = thisTry.targetSite; + logger.info( + "CHOOSING " + + server.type + + " " + + server.hostname + + ":" + + server.port + + ", auth method " + + server.auth + + (server.authAlternatives.length + ? " " + server.authAlternatives.join(",") + : "") + + ", socketType " + + server.socketType + + (server.badCert ? " (bad cert!)" : "") + ); + }; + + var outgoingSuccess = function (thisTry, alternativeTries) { + assert(thisTry.protocol == SMTP, "I only know SMTP for outgoing"); + // Ensure there are no previously saved outgoing errors, if we've got + // success here. + outgoingEx = null; + HostTryToAccountServer(thisTry, resultConfig.outgoing); + + for (let alternativeTry of alternativeTries) { + // resultConfig.createNewOutgoing(); misses username etc., so copy + let altServer = deepCopy(resultConfig.outgoing); + HostTryToAccountServer(alternativeTry, altServer); + assert(resultConfig.outgoingAlternatives); + resultConfig.outgoingAlternatives.push(altServer); + } + + progressCallback( + resultConfig.outgoing.type, + resultConfig.outgoing.hostname, + resultConfig.outgoing.port, + resultConfig.outgoing.socketType, + true, + resultConfig + ); + outgoingDone = true; + checkDone(); + }; + + var incomingSuccess = function (thisTry, alternativeTries) { + // Ensure there are no previously saved incoming errors, if we've got + // success here. + incomingEx = null; + HostTryToAccountServer(thisTry, resultConfig.incoming); + + for (let alternativeTry of alternativeTries) { + // resultConfig.createNewIncoming(); misses username etc., so copy + let altServer = deepCopy(resultConfig.incoming); + HostTryToAccountServer(alternativeTry, altServer); + assert(resultConfig.incomingAlternatives); + resultConfig.incomingAlternatives.push(altServer); + } + + progressCallback( + resultConfig.incoming.type, + resultConfig.incoming.hostname, + resultConfig.incoming.port, + resultConfig.incoming.socketType, + true, + resultConfig + ); + incomingDone = true; + checkDone(); + }; + + var incomingError = function (ex) { + incomingEx = ex; + checkDone(); + incomingHostDetector.cancel(new CancelOthersException()); + outgoingHostDetector.cancel(new CancelOthersException()); + }; + + var outgoingError = function (ex) { + outgoingEx = ex; + checkDone(); + incomingHostDetector.cancel(new CancelOthersException()); + outgoingHostDetector.cancel(new CancelOthersException()); + }; + + incomingHostDetector = new IncomingHostDetector( + progress, + incomingSuccess, + incomingError + ); + outgoingHostDetector = new OutgoingHostDetector( + progress, + outgoingSuccess, + outgoingError + ); + if (which == "incoming" || which == "both") { + incomingHostDetector.start( + resultConfig.incoming.hostname ? resultConfig.incoming.hostname : domain, + !!resultConfig.incoming.hostname, + resultConfig.incoming.type, + resultConfig.incoming.port, + resultConfig.incoming.socketType, + resultConfig.incoming.auth + ); + } + if (which == "outgoing" || which == "both") { + outgoingHostDetector.start( + resultConfig.outgoing.hostname ? resultConfig.outgoing.hostname : domain, + !!resultConfig.outgoing.hostname, + "smtp", + resultConfig.outgoing.port, + resultConfig.outgoing.socketType, + resultConfig.outgoing.auth + ); + } + + return new GuessAbortable(incomingHostDetector, outgoingHostDetector); +} + +function GuessAbortable(incomingHostDetector, outgoingHostDetector) { + Abortable.call(this); + this._incomingHostDetector = incomingHostDetector; + this._outgoingHostDetector = outgoingHostDetector; +} +GuessAbortable.prototype = Object.create(Abortable.prototype); +GuessAbortable.prototype.constructor = GuessAbortable; +GuessAbortable.prototype.cancel = function (ex) { + this._incomingHostDetector.cancel(ex); + this._outgoingHostDetector.cancel(ex); +}; + +// -------------- +// Implementation + +// Objects, functions and constants that follow are not to be used outside +// this file. +var kNotTried = 0; +var kOngoing = 1; +var kFailed = 2; +var kSuccess = 3; + +/** + * Internal object holding one server that we should try or did try. + * Used as |thisTry|. + * + * Note: The consts it uses for protocol is defined towards the end of this file + * and not the same as those used in AccountConfig (type). (fix + * this) + */ +function HostTry() {} +HostTry.prototype = { + // IMAP, POP or SMTP + protocol: UNKNOWN, + // {String} + hostname: undefined, + // {Integer} + port: undefined, + // {nsMsgSocketType} + socketType: UNKNOWN, + // {String} what to send to server + commands: null, + // {Integer-enum} kNotTried, kOngoing, kFailed or kSuccess + status: kNotTried, + // {Abortable} allows to cancel the socket comm + abortable: null, + + // {Array of {Integer-enum}} @see _advertisesAuthMethods() result + // Info about the server, from the protocol and SSL chat + authMethods: null, + // {String} Whether the SSL cert is not from a proper CA + selfSignedCert: false, + // {String} Which host the SSL cert is made for, if not hostname. + // If set, this is an SSL error. + targetSite: null, +}; + +/** + * When the success or errorCallbacks are called to abort the other requests + * which happened in parallel, this ex is used as param for cancel(), so that + * the cancel doesn't trigger another callback. + */ +function CancelOthersException() { + CancelledException.call(this, "we're done, cancelling the other probes"); +} +CancelOthersException.prototype = Object.create(CancelledException.prototype); +CancelOthersException.prototype.constructor = CancelOthersException; + +/** + * @param successCallback {function(result {HostTry}, alts {Array of HostTry})} + * Called when the config is OK + * |result| is the most preferred server. + * |alts| currently exists only for |IncomingHostDetector| and contains + * some servers of the other type (POP3 instead of IMAP), if available. + * @param errorCallback {function(ex)} Called when we could not find a config + * @param progressCallback { function(server {HostTry}) } Called when we tried + * (will try?) a new hostname and port + */ +function HostDetector(progressCallback, successCallback, errorCallback) { + this.mSuccessCallback = successCallback; + this.mProgressCallback = progressCallback; + this.mErrorCallback = errorCallback; + this._cancel = false; + // {Array of {HostTry}}, ordered by decreasing preference + this._hostsToTry = []; + + // init logging + this._log = gAccountSetupLogger; + this._log.info("created host detector"); +} + +HostDetector.prototype = { + cancel(ex) { + this._cancel = true; + // We have to actively stop the network calls, as they may result in + // callbacks e.g. to the cert handler. If the dialog is gone by the time + // this happens, the javascript stack is horked. + for (let i = 0; i < this._hostsToTry.length; i++) { + let thisTry = this._hostsToTry[i]; // {HostTry} + if (thisTry.abortable) { + thisTry.abortable.cancel(ex); + } + thisTry.status = kFailed; // or don't set? Maybe we want to continue. + } + if (ex instanceof CancelOthersException) { + return; + } + if (!ex) { + ex = new CancelledException(); + } + this.mErrorCallback(ex); + }, + + /** + * Start the detection. + * + * @param {string} domain - Domain to be used as base for guessing. + * Should be a domain (e.g. yahoo.co.uk). + * If hostIsPrecise == true, it should be a full hostname. + * @param {boolean} hostIsPrecise - If true, use only this hostname, + * do not guess hostnames. + * @param {"pop3"|"imap"|"exchange"|"smtp"|""} - Account type. + * @param {integer} port - The port to use. 0 to autodetect + * @param {nsMsgSocketType|-1} socketType - Socket type. -1 to autodetect. + * @param {nsMsgAuthMethod|0} authMethod - Authentication method. 0 to autodetect. + */ + start(domain, hostIsPrecise, type, port, socketType, authMethod) { + domain = domain.replace(/\s*/g, ""); // Remove whitespace + if (!hostIsPrecise) { + hostIsPrecise = false; + } + var protocol = lazy.Sanitizer.translate( + type, + { imap: IMAP, pop3: POP, smtp: SMTP }, + UNKNOWN + ); + if (!port) { + port = UNKNOWN; + } + var ssl_only = Services.prefs.getBoolPref( + "mailnews.auto_config.guess.sslOnly" + ); + this._cancel = false; + this._log.info( + `Starting ${protocol} detection on ${ + !hostIsPrecise ? "~ " : "" + }${domain}:${port} with socketType=${socketType} and authMethod=${authMethod}` + ); + + // fill this._hostsToTry + this._hostsToTry = []; + var hostnamesToTry = []; + // if hostIsPrecise is true, it's because that's what the user input + // explicitly, and we'll just try it, nothing else. + if (hostIsPrecise) { + hostnamesToTry.push(domain); + } else { + hostnamesToTry = this._hostnamesToTry(protocol, domain); + } + + for (let i = 0; i < hostnamesToTry.length; i++) { + let hostname = hostnamesToTry[i]; + let hostEntries = this._portsToTry(hostname, protocol, socketType, port); + for (let j = 0; j < hostEntries.length; j++) { + let hostTry = hostEntries[j]; // from getHostEntry() + if (ssl_only && hostTry.socketType == NONE) { + continue; + } + hostTry.hostname = hostname; + hostTry.status = kNotTried; + hostTry.desc = + hostTry.hostname + + ":" + + hostTry.port + + " socketType=" + + hostTry.socketType + + " " + + protocolToString(hostTry.protocol); + hostTry.authMethod = authMethod; + this._hostsToTry.push(hostTry); + } + } + + this._hostsToTry = sortTriesByPreference(this._hostsToTry); + this._tryAll(); + }, + + // We make all host/port combinations run in parallel, store their + // results in an array, and as soon as one finishes successfully and all + // higher-priority ones have failed, we abort all lower-priority ones. + + _tryAll() { + if (this._cancel) { + return; + } + var me = this; + var timeout = Services.prefs.getIntPref( + "mailnews.auto_config.guess.timeout" + ); + // We assume we'll resolve the same proxy for all tries, and + // proceed to use the first resolved proxy for all tries. This + // assumption is generally sound, but not always: mechanisms like + // the pref network.proxy.no_proxies_on can make imap.domain and + // pop.domain resolve differently. + doProxy(this._hostsToTry[0].hostname, function (proxy) { + for (let i = 0; i < me._hostsToTry.length; i++) { + let thisTry = me._hostsToTry[i]; // {HostTry} + if (thisTry.status != kNotTried) { + continue; + } + me._log.info(thisTry.desc + ": initializing probe..."); + if (i == 0) { + // showing 50 servers at once is pointless + me.mProgressCallback(thisTry); + } + + thisTry.abortable = SocketUtil( + thisTry.hostname, + thisTry.port, + thisTry.socketType, + thisTry.commands, + timeout, + proxy, + new SSLErrorHandler(thisTry, me._log), + function (wiredata) { + // result callback + if (me._cancel) { + // Don't use response anymore. + return; + } + me.mProgressCallback(thisTry); + me._processResult(thisTry, wiredata); + me._checkFinished(); + }, + function (e) { + // error callback + if (me._cancel) { + // Who set cancel to true already called mErrorCallback(). + return; + } + me._log.warn(thisTry.desc + ": " + e); + thisTry.status = kFailed; + me._checkFinished(); + } + ); + thisTry.status = kOngoing; + } + }); + }, + + /** + * @param {HostTry} thisTry + * @param {string[]} wiredata - What the server returned in response to our protocol chat. + */ + _processResult(thisTry, wiredata) { + if (thisTry._gotCertError) { + if (thisTry._gotCertError == Ci.nsICertOverrideService.ERROR_MISMATCH) { + thisTry._gotCertError = 0; + thisTry.status = kFailed; + return; + } + + if ( + thisTry._gotCertError == Ci.nsICertOverrideService.ERROR_UNTRUSTED || + thisTry._gotCertError == Ci.nsICertOverrideService.ERROR_TIME + ) { + this._log.info( + thisTry.desc + ": TRYING AGAIN, hopefully with exception recorded" + ); + thisTry._gotCertError = 0; + thisTry.selfSignedCert = true; // _next_ run gets this exception + thisTry.status = kNotTried; // try again (with exception) + this._tryAll(); + return; + } + } + + if (wiredata == null || wiredata === undefined) { + this._log.info(thisTry.desc + ": no data"); + thisTry.status = kFailed; + return; + } + this._log.info(thisTry.desc + ": wiredata: " + wiredata.join("")); + thisTry.authMethods = this._advertisesAuthMethods( + thisTry.protocol, + wiredata + ); + if ( + thisTry.socketType == STARTTLS && + !this._hasSTARTTLS(thisTry, wiredata) + ) { + this._log.info(thisTry.desc + ": STARTTLS wanted, but not offered"); + thisTry.status = kFailed; + return; + } + this._log.info( + thisTry.desc + + ": success" + + (thisTry.selfSignedCert ? " (selfSignedCert)" : "") + ); + thisTry.status = kSuccess; + + if (thisTry.selfSignedCert) { + // eh, ERROR_UNTRUSTED or ERROR_TIME + // We clear the temporary override now after success. If we clear it + // earlier we get into an infinite loop, probably because the cert + // remembering is temporary and the next try gets a new connection which + // isn't covered by that temporariness. + this._log.info( + thisTry.desc + ": clearing validity override for " + thisTry.hostname + ); + Cc["@mozilla.org/security/certoverride;1"] + .getService(Ci.nsICertOverrideService) + .clearValidityOverride(thisTry.hostname, thisTry.port, {}); + } + }, + + _checkFinished() { + var successfulTry = null; + var successfulTryAlternative = null; // POP3 + var unfinishedBusiness = false; + // this._hostsToTry is ordered by decreasing preference + for (let i = 0; i < this._hostsToTry.length; i++) { + let thisTry = this._hostsToTry[i]; + if (thisTry.status == kNotTried || thisTry.status == kOngoing) { + unfinishedBusiness = true; + } else if (thisTry.status == kSuccess && !unfinishedBusiness) { + // thisTry is good, and all higher preference tries failed, so use this + if (!successfulTry) { + successfulTry = thisTry; + if (successfulTry.protocol == SMTP) { + break; + } + } else if (successfulTry.protocol != thisTry.protocol) { + successfulTryAlternative = thisTry; + break; + } + } + } + if (successfulTry && (successfulTryAlternative || !unfinishedBusiness)) { + this.mSuccessCallback( + successfulTry, + successfulTryAlternative ? [successfulTryAlternative] : [] + ); + this.cancel(new CancelOthersException()); + } else if (!unfinishedBusiness) { + // all failed + this._log.info("ran out of options"); + var errorMsg = getStringBundle( + "chrome://messenger/locale/accountCreationModel.properties" + ).GetStringFromName("cannot_find_server.error"); + this.mErrorCallback(new Exception(errorMsg)); + // no need to cancel, all failed + } + // else let ongoing calls continue + }, + + /** + * Which auth mechanism the server claims to support. + * That doesn't necessarily reflect reality, it is more an upper bound. + * + * @param {integer} protocol - IMAP, POP or SMTP + * @param {string[]} capaResponse - On the wire data that the server returned. + * May be the full exchange or just capa. + * @returns {nsMsgAuthMethod[]} Advertised authentication methods, + * in decreasing order of preference. + * E.g. [ nsMsgAuthMethod.GSSAPI, nsMsgAuthMethod.passwordEncrypted ] + * for a server that supports only Kerberos and encrypted passwords. + */ + _advertisesAuthMethods(protocol, capaResponse) { + // For IMAP, capabilities include e.g.: + // "AUTH=CRAM-MD5", "AUTH=NTLM", "AUTH=GSSAPI", "AUTH=MSN", "AUTH=PLAIN" + // for POP3, the auth mechanisms are returned in capa as the following: + // "CRAM-MD5", "NTLM", "MSN", "GSSAPI" + // For SMTP, EHLO will return AUTH and then a list of the + // mechanism(s) supported, e.g., + // AUTH LOGIN NTLM MSN CRAM-MD5 GSSAPI + var supported = new Set(); + var line = capaResponse.join("\n").toUpperCase(); + var prefix = ""; + if (protocol == POP) { + prefix = ""; + } else if (protocol == IMAP) { + prefix = "AUTH="; + } else if (protocol == SMTP) { + prefix = "AUTH.*"; + } else { + throw NotReached("must pass protocol"); + } + // add in decreasing order of preference + if (new RegExp(prefix + "GSSAPI").test(line)) { + supported.add(Ci.nsMsgAuthMethod.GSSAPI); + } + if (new RegExp(prefix + "CRAM-MD5").test(line)) { + supported.add(Ci.nsMsgAuthMethod.passwordEncrypted); + } + if (new RegExp(prefix + "(NTLM|MSN)").test(line)) { + supported.add(Ci.nsMsgAuthMethod.NTLM); + } + if (new RegExp(prefix + "LOGIN").test(line)) { + supported.add(Ci.nsMsgAuthMethod.passwordCleartext); + } + if (new RegExp(prefix + "PLAIN").test(line)) { + supported.add(Ci.nsMsgAuthMethod.passwordCleartext); + } + if (protocol != IMAP || !line.includes("LOGINDISABLED")) { + supported.add(Ci.nsMsgAuthMethod.passwordCleartext); + } + // The array elements will be in the Set's order of addition. + return Array.from(supported); + }, + + _hasSTARTTLS(thisTry, wiredata) { + var capa = thisTry.protocol == POP ? "STLS" : "STARTTLS"; + return ( + thisTry.socketType == STARTTLS && + wiredata.join("").toUpperCase().includes(capa) + ); + }, +}; + +/** + * @param {nsMsgAuthMethod[]} authMethods - Authentication methods to choose from. + * See return value of _advertisesAuthMethods() + * Note: the returned auth method will be removed from the array. + * @returns {nsMsgAuthMethod} one of them, the preferred one + * Note: this might be Kerberos, which might not actually work, + * so you might need to try the others, too. + */ +function chooseBestAuthMethod(authMethods) { + if (!authMethods || !authMethods.length) { + return Ci.nsMsgAuthMethod.passwordCleartext; + } + return authMethods.shift(); // take first (= most preferred) +} + +function IncomingHostDetector( + progressCallback, + successCallback, + errorCallback +) { + HostDetector.call(this, progressCallback, successCallback, errorCallback); +} +IncomingHostDetector.prototype = { + __proto__: HostDetector.prototype, + _hostnamesToTry(protocol, domain) { + var hostnamesToTry = []; + if (protocol != POP) { + hostnamesToTry.push("imap." + domain); + } + if (protocol != IMAP) { + hostnamesToTry.push("pop3." + domain); + hostnamesToTry.push("pop." + domain); + } + hostnamesToTry.push("mail." + domain); + hostnamesToTry.push(domain); + return hostnamesToTry; + }, + _portsToTry: getIncomingTryOrder, +}; + +function OutgoingHostDetector( + progressCallback, + successCallback, + errorCallback +) { + HostDetector.call(this, progressCallback, successCallback, errorCallback); +} +OutgoingHostDetector.prototype = { + __proto__: HostDetector.prototype, + _hostnamesToTry(protocol, domain) { + var hostnamesToTry = []; + hostnamesToTry.push("smtp." + domain); + hostnamesToTry.push("mail." + domain); + hostnamesToTry.push(domain); + return hostnamesToTry; + }, + _portsToTry: getOutgoingTryOrder, +}; + +// --------------------------------------------- +// Encode protocol ports and order of preference + +// Protocol Types +var UNKNOWN = -1; +var IMAP = 0; +var POP = 1; +var SMTP = 2; +// Security Types +var NONE = Ci.nsMsgSocketType.plain; +var STARTTLS = Ci.nsMsgSocketType.alwaysSTARTTLS; +var SSL = Ci.nsMsgSocketType.SSL; + +var IMAP_PORTS = {}; +IMAP_PORTS[NONE] = 143; +IMAP_PORTS[STARTTLS] = 143; +IMAP_PORTS[SSL] = 993; + +var POP_PORTS = {}; +POP_PORTS[NONE] = 110; +POP_PORTS[STARTTLS] = 110; +POP_PORTS[SSL] = 995; + +var SMTP_PORTS = {}; +SMTP_PORTS[NONE] = 587; +SMTP_PORTS[STARTTLS] = 587; +SMTP_PORTS[SSL] = 465; + +var CMDS = {}; +CMDS[IMAP] = ["1 CAPABILITY\r\n", "2 LOGOUT\r\n"]; +CMDS[POP] = ["CAPA\r\n", "QUIT\r\n"]; +CMDS[SMTP] = ["EHLO we-guess.mozilla.org\r\n", "QUIT\r\n"]; + +/** + * Sort by preference of SSL, IMAP etc. + * + * @param tries {Array of {HostTry}} + * @returns {Array of {HostTry}} + */ +function sortTriesByPreference(tries) { + return tries.sort(function (a, b) { + // -1 = a is better; 1 = b is better; 0 = equal + // Prefer SSL/STARTTLS above all else + if (a.socketType != NONE && b.socketType == NONE) { + return -1; + } + if (b.socketType != NONE && a.socketType == NONE) { + return 1; + } + // Prefer IMAP over POP + if (a.protocol == IMAP && b.protocol == POP) { + return -1; + } + if (b.protocol == IMAP && a.protocol == POP) { + return 1; + } + // Prefer SSL/TLS over STARTTLS + if (a.socketType == SSL && b.socketType == STARTTLS) { + return -1; + } + if (a.socketType == STARTTLS && b.socketType == SSL) { + return 1; + } + // For hostnames, leave existing sorting, as in _hostnamesToTry() + // For ports, leave existing sorting, as in getOutgoingTryOrder() + return 0; + }); +} + +/** + * @returns {HostTry[]} Hosts to try. + */ +function getIncomingTryOrder(host, protocol, socketType, port) { + var lowerCaseHost = host.toLowerCase(); + + if ( + protocol == UNKNOWN && + (lowerCaseHost.startsWith("pop.") || lowerCaseHost.startsWith("pop3.")) + ) { + protocol = POP; + } else if (protocol == UNKNOWN && lowerCaseHost.startsWith("imap.")) { + protocol = IMAP; + } + + if (protocol != UNKNOWN) { + if (socketType == UNKNOWN) { + return [ + getHostEntry(protocol, STARTTLS, port), + getHostEntry(protocol, SSL, port), + getHostEntry(protocol, NONE, port), + ]; + } + return [getHostEntry(protocol, socketType, port)]; + } + if (socketType == UNKNOWN) { + return [ + getHostEntry(IMAP, STARTTLS, port), + getHostEntry(IMAP, SSL, port), + getHostEntry(POP, STARTTLS, port), + getHostEntry(POP, SSL, port), + getHostEntry(IMAP, NONE, port), + getHostEntry(POP, NONE, port), + ]; + } + return [ + getHostEntry(IMAP, socketType, port), + getHostEntry(POP, socketType, port), + ]; +} + +/** + * @returns {Array of {HostTry}} + */ +function getOutgoingTryOrder(host, protocol, socketType, port) { + assert(protocol == SMTP, "need SMTP as protocol for outgoing"); + if (socketType == UNKNOWN) { + if (port == UNKNOWN) { + // neither SSL nor port known + return [ + getHostEntry(SMTP, STARTTLS, UNKNOWN), + getHostEntry(SMTP, STARTTLS, 25), + getHostEntry(SMTP, SSL, UNKNOWN), + getHostEntry(SMTP, NONE, UNKNOWN), + getHostEntry(SMTP, NONE, 25), + ]; + } + // port known, SSL not + return [ + getHostEntry(SMTP, STARTTLS, port), + getHostEntry(SMTP, SSL, port), + getHostEntry(SMTP, NONE, port), + ]; + } + // SSL known, port not + if (port == UNKNOWN) { + if (socketType == SSL) { + return [getHostEntry(SMTP, SSL, UNKNOWN)]; + } + return [ + getHostEntry(SMTP, socketType, UNKNOWN), + getHostEntry(SMTP, socketType, 25), + ]; + } + // SSL and port known + return [getHostEntry(SMTP, socketType, port)]; +} + +/** + * @returns {HostTry} with proper default port and commands, + * but without hostname. + */ +function getHostEntry(protocol, socketType, port) { + if (!port || port == UNKNOWN) { + switch (protocol) { + case POP: + port = POP_PORTS[socketType]; + break; + case IMAP: + port = IMAP_PORTS[socketType]; + break; + case SMTP: + port = SMTP_PORTS[socketType]; + break; + default: + throw new NotReached("unsupported protocol " + protocol); + } + } + + var r = new HostTry(); + r.protocol = protocol; + r.socketType = socketType; + r.port = port; + r.commands = CMDS[protocol]; + return r; +} + +// here -> AccountConfig +function protocolToString(type) { + if (type == IMAP) { + return "imap"; + } + if (type == POP) { + return "pop3"; + } + if (type == SMTP) { + return "smtp"; + } + throw new NotReached("unexpected protocol"); +} + +// ---------------------- +// SSL cert error handler + +/** + * @param thisTry {HostTry} + * @param logger {ConsoleAPI} + */ +function SSLErrorHandler(thisTry, logger) { + this._try = thisTry; + this._log = logger; + // _ gotCertError will be set to an error code (one of those defined in + // nsICertOverrideService) + this._gotCertError = 0; +} +SSLErrorHandler.prototype = { + processCertError(secInfo, targetSite) { + this._log.error("Got Cert error for " + targetSite); + + if (!secInfo) { + return; + } + + let cert = secInfo.serverCert; + + let parts = targetSite.split(":"); + let host = parts[0]; + let port = parts[1]; + + /* The following 2 cert problems are unfortunately common: + * 1) hostname mismatch: + * user is customer at a domain hoster, he owns yourname.org, + * and the IMAP server is imap.hoster.com (but also reachable as + * imap.yourname.org), and has a cert for imap.hoster.com. + * 2) self-signed: + * a company has an internal IMAP server, and it's only for + * 30 employees, and they didn't want to buy a cert, so + * they use a self-signed cert. + * + * We would like the above to pass, somehow, with user confirmation. + * The following case should *not* pass: + * + * 1) MITM + * User has @gmail.com, and an attacker is between the user and + * the Internet and runs a man-in-the-middle (MITM) attack. + * Attacker controls DNS and sends imap.gmail.com to his own + * imap.attacker.com. He has either a valid, CA-issued + * cert for imap.attacker.com, or a self-signed cert. + * Of course, attacker.com could also be legit-sounding gmailservers.com. + * + * What makes it dangerous is that we (!) propose the server to the user, + * and he cannot judge whether imap.gmailservers.com is correct or not, + * and he will likely approve it. + */ + + if (secInfo.isDomainMismatch) { + this._try._gotCertError = Ci.nsICertOverrideService.ERROR_MISMATCH; + } else if (secInfo.isUntrusted) { + // e.g. self-signed + this._try._gotCertError = Ci.nsICertOverrideService.ERROR_UNTRUSTED; + } else if (secInfo.isNotValidAtThisTime) { + this._try._gotCertError = Ci.nsICertOverrideService.ERROR_TIME; + } else { + this._try._gotCertError = -1; // other + } + + /* We will add a temporary cert exception here, so that + * we can continue and connect and try. + * But we will remove it again as soon as we close the + * connection, in _processResult(). + * _gotCertError will serve as the marker that we + * have to clear the override later. + * + * In verifyConfig(), before we send the password, we *must* + * get another cert exception, this time with dialog to the user + * so that he gets informed about this and can make a choice. + */ + this._try.targetSite = targetSite; + Cc["@mozilla.org/security/certoverride;1"] + .getService(Ci.nsICertOverrideService) + .rememberValidityOverride(host, port, {}, cert, true); // temporary override + this._log.warn(`Added temporary override of bad cert for: ${host}:${port}`); + }, +}; + +// ----------- +// Socket Util + +/** + * @param hostname {String} The DNS hostname to connect to. + * @param port {Integer} The numeric port to connect to on the host. + * @param socketType {nsMsgSocketType} SSL, STARTTLS or NONE + * @param commands {Array of String}: protocol commands + * to send to the server. + * @param timeout {Integer} seconds to wait for a server response, then cancel. + * @param proxy {nsIProxyInfo} The proxy to use (or null to not use any). + * @param sslErrorHandler {SSLErrorHandler} + * @param resultCallback {function(wiredata)} This function will + * be called with the result string array from the server + * or null if no communication occurred. + * @param errorCallback {function(e)} + */ +function SocketUtil( + hostname, + port, + socketType, + commands, + timeout, + proxy, + sslErrorHandler, + resultCallback, + errorCallback +) { + assert(commands && commands.length, "need commands"); + + var index = 0; // commands[index] is next to send to server + var initialized = false; + var aborted = false; + + function _error(e) { + if (aborted) { + return; + } + aborted = true; + errorCallback(e); + } + + function timeoutFunc() { + if (!initialized) { + _error("timeout"); + } + } + + // In case DNS takes too long or does not resolve or another blocking + // issue occurs before the timeout can be set on the socket, this + // ensures that the listener callback will be fired in a timely manner. + // XXX There might to be some clean up needed after the timeout is fired + // for socket and io resources. + + // The timeout value plus 2 seconds + setTimeout(timeoutFunc, timeout * 1000 + 2000); + + var transportService = Cc[ + "@mozilla.org/network/socket-transport-service;1" + ].getService(Ci.nsISocketTransportService); + + // @see NS_NETWORK_SOCKET_CONTRACTID_PREFIX + var socketTypeName; + if (socketType == SSL) { + socketTypeName = ["ssl"]; + } else if (socketType == STARTTLS) { + socketTypeName = ["starttls"]; + } else { + socketTypeName = []; + } + var transport = transportService.createTransport( + socketTypeName, + hostname, + port, + proxy, + null + ); + + transport.setTimeout(Ci.nsISocketTransport.TIMEOUT_CONNECT, timeout); + transport.setTimeout(Ci.nsISocketTransport.TIMEOUT_READ_WRITE, timeout); + + var outstream = transport.openOutputStream(0, 0, 0); + var stream = transport.openInputStream(0, 0, 0); + var instream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance( + Ci.nsIScriptableInputStream + ); + instream.init(stream); + + var dataListener = { + data: [], + onStartRequest(request) { + try { + initialized = true; + if (!aborted) { + // Send the first request + let outputData = commands[index++]; + outstream.write(outputData, outputData.length); + } + } catch (e) { + _error(e); + } + }, + async onStopRequest(request, status) { + try { + instream.close(); + outstream.close(); + // Did it fail because of a bad certificate? + let isCertError = false; + if (!Components.isSuccessCode(status)) { + let nssErrorsService = Cc[ + "@mozilla.org/nss_errors_service;1" + ].getService(Ci.nsINSSErrorsService); + try { + let errorType = nssErrorsService.getErrorClass(status); + if (errorType == Ci.nsINSSErrorsService.ERROR_CLASS_BAD_CERT) { + isCertError = true; + } + } catch (e) { + // nsINSSErrorsService.getErrorClass throws if given a non-STARTTLS, + // non-cert error, so ignore this. + } + } + if (isCertError) { + if ( + Services.prefs.getBoolPref( + "mailnews.auto_config.guess.requireGoodCert", + true + ) + ) { + gAccountSetupLogger.info( + `Bad (overridable) certificate for ${hostname}:${port}. Set mailnews.auto_config.guess.requireGoodCert to false to allow detecting this as a valid SSL/TLS configuration` + ); + } else { + let socketTransport = transport.QueryInterface( + Ci.nsISocketTransport + ); + let secInfo = + await socketTransport.tlsSocketControl?.asyncGetSecurityInfo(); + sslErrorHandler.processCertError(secInfo, hostname + ":" + port); + } + } + resultCallback(this.data.length ? this.data : null); + } catch (e) { + _error(e); + } + }, + onDataAvailable(request, inputStream, offset, count) { + try { + if (!aborted) { + let inputData = instream.read(count); + this.data.push(inputData); + if (index < commands.length) { + // Send the next request to the server. + let outputData = commands[index++]; + outstream.write(outputData, outputData.length); + } + } + } catch (e) { + _error(e); + } + }, + }; + + try { + var pump = Cc["@mozilla.org/network/input-stream-pump;1"].createInstance( + Ci.nsIInputStreamPump + ); + + pump.init(stream, 0, 0, false); + pump.asyncRead(dataListener); + return new SocketAbortable(transport); + } catch (e) { + _error(e); + } + return null; +} + +function SocketAbortable(transport) { + Abortable.call(this); + assert(transport instanceof Ci.nsITransport, "need transport"); + this._transport = transport; +} +SocketAbortable.prototype = Object.create(Abortable.prototype); +SocketAbortable.prototype.constructor = UserCancelledException; +SocketAbortable.prototype.cancel = function (ex) { + try { + this._transport.close(Cr.NS_ERROR_ABORT); + } catch (e) { + ddump("canceling socket failed: " + e); + } +}; + +/** + * Resolve a proxy for some domain and expose it via a callback. + * + * @param hostname {String} The hostname which a proxy will be resolved for + * @param resultCallback {function(proxyInfo)} + * Called after the proxy has been resolved for hostname. + * proxy {nsIProxyInfo} The resolved proxy, or null if none were found + * for hostname + */ +function doProxy(hostname, resultCallback) { + // This implements the nsIProtocolProxyCallback interface: + function ProxyResolveCallback() {} + ProxyResolveCallback.prototype = { + onProxyAvailable(req, uri, proxy, status) { + // Anything but a SOCKS proxy will be unusable for email. + if (proxy != null && proxy.type != "socks" && proxy.type != "socks4") { + proxy = null; + } + resultCallback(proxy); + }, + }; + var proxyService = Cc[ + "@mozilla.org/network/protocol-proxy-service;1" + ].getService(Ci.nsIProtocolProxyService); + // Use some arbitrary scheme just because it is required... + var uri = Services.io.newURI("http://" + hostname); + // ... we'll ignore it any way. We prefer SOCKS since that's the + // only thing we can use for email protocols. + var proxyFlags = + Ci.nsIProtocolProxyService.RESOLVE_IGNORE_URI_SCHEME | + Ci.nsIProtocolProxyService.RESOLVE_PREFER_SOCKS_PROXY; + if (Services.prefs.getBoolPref("network.proxy.socks_remote_dns")) { + proxyFlags |= Ci.nsIProtocolProxyService.RESOLVE_ALWAYS_TUNNEL; + } + proxyService.asyncResolve(uri, proxyFlags, new ProxyResolveCallback()); +} + +var GuessConfig = { + UNKNOWN, + IMAP, + POP, + SMTP, + NONE, + STARTTLS, + SSL, + getHostEntry, + getIncomingTryOrder, + getOutgoingTryOrder, + guessConfig, +}; diff --git a/comm/mail/components/accountcreation/Sanitizer.jsm b/comm/mail/components/accountcreation/Sanitizer.jsm new file mode 100644 index 0000000000..d6bc3918bc --- /dev/null +++ b/comm/mail/components/accountcreation/Sanitizer.jsm @@ -0,0 +1,249 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const EXPORTED_SYMBOLS = ["Sanitizer"]; + +const { AccountCreationUtils } = ChromeUtils.import( + "resource:///modules/accountcreation/AccountCreationUtils.jsm" +); + +const { cleanUpHostName, isLegalHostNameOrIP } = ChromeUtils.import( + "resource:///modules/hostnameUtils.jsm" +); + +/** + * This is a generic input validation lib. Use it when you process + * data from the network. + * + * Just a few functions which verify, for security purposes, that the + * input variables (strings, if nothing else is noted) are of the expected + * type and syntax. + * + * The functions take a string (unless noted otherwise) and return + * the expected datatype in JS types. If the value is not as expected, + * they throw exceptions. + */ + +// To debug, set mail.setup.loglevel="All" and kDebug = true. +var kDebug = false; + +var Sanitizer = { + integer(unchecked) { + if (typeof unchecked == "number" && !isNaN(unchecked)) { + return unchecked; + } + + var r = parseInt(unchecked); + if (isNaN(r)) { + throw new MalformedException("no_number.error", unchecked); + } + + return r; + }, + + integerRange(unchecked, min, max) { + var int = this.integer(unchecked); + if (int < min) { + throw new MalformedException("number_too_small.error", unchecked); + } + + if (int > max) { + throw new MalformedException("number_too_large.error", unchecked); + } + + return int; + }, + + boolean(unchecked) { + if (typeof unchecked == "boolean") { + return unchecked; + } + + if (unchecked == "true") { + return true; + } + + if (unchecked == "false") { + return false; + } + + throw new MalformedException("boolean.error", unchecked); + }, + + string(unchecked) { + return String(unchecked); + }, + + nonemptystring(unchecked) { + if (!unchecked) { + throw new MalformedException("string_empty.error", unchecked); + } + + return this.string(unchecked); + }, + + /** + * Allow only letters, numbers, "-" and "_". + * + * Empty strings not allowed (good idea?). + */ + alphanumdash(unchecked) { + var str = this.nonemptystring(unchecked); + if (!/^[a-zA-Z0-9\-\_]*$/.test(str)) { + throw new MalformedException("alphanumdash.error", unchecked); + } + + return str; + }, + + /** + * DNS hostnames like foo.bar.example.com + * Allow only letters, numbers, "-" and "." + * Empty strings not allowed. + * Currently does not support IDN (international domain names). + */ + hostname(unchecked) { + let str = cleanUpHostName(this.nonemptystring(unchecked)); + + // Allow placeholders. TODO move to a new hostnameOrPlaceholder() + // The regex is "anything, followed by one or more (placeholders than + // anything)". This doesn't catch the non-placeholder case, but that's + // handled down below. + if (/^[a-zA-Z0-9\-\.]*(%[A-Z0-9]+%[a-zA-Z0-9\-\.]*)+$/.test(str)) { + return str; + } + + if (!isLegalHostNameOrIP(str)) { + throw new MalformedException("hostname_syntax.error", unchecked); + } + + return str.toLowerCase(); + }, + + /** + * A value which resembles an email address. + */ + emailAddress(unchecked) { + let str = this.nonemptystring(unchecked); + if (!/^[a-z0-9\-%+_\.\*]+@[a-z0-9\-\.]+\.[a-z]+$/i.test(str)) { + throw new MalformedException("emailaddress_syntax.error", unchecked); + } + + return str.toLowerCase(); + }, + + /** + * A non-chrome URL that's safe to request. + */ + url(unchecked) { + var str = this.string(unchecked); + + // DANGER ZONE: data:text/javascript or data:text/html can contain + // JavaScript code, run in the caller's security context, and might allow + // arbitrary code execution, so these must be prevented at all costs. + // PNG and JPEG data: URLs are fine. But SVG is again dangerous, + // it can contain javascript, so it would create a critical security hole. + // Talk to BenB or bz before relaxing *any* of the checks in this function. + if ( + str.startsWith("data:image/png;") || + str.startsWith("data:image/jpeg;") + ) { + return new URL(str).href; + } + + if (!str.startsWith("http:") && !str.startsWith("https:")) { + throw new MalformedException("url_scheme.error", unchecked); + } + + var uri; + try { + uri = Services.io.newURI(str); + uri = uri.QueryInterface(Ci.nsIURL); + } catch (e) { + throw new MalformedException("url_parsing.error", unchecked); + } + + if (uri.scheme != "http" && uri.scheme != "https") { + throw new MalformedException("url_scheme.error", unchecked); + } + + return uri.spec; + }, + + /** + * A value which should be shown to the user in the UI as label + */ + label(unchecked) { + return this.string(unchecked); + }, + + /** + * Allows only certain values as input, otherwise throw. + * + * @param unchecked {Any} The value to check + * @param allowedValues {Array} List of values that |unchecked| may have. + * @param defaultValue {Any} (Optional) If |unchecked| does not match + * anything in |mapping|, a |defaultValue| can be returned instead of + * throwing an exception. The latter is the default and happens when + * no |defaultValue| is passed. + * @throws MalformedException + */ + enum(unchecked, allowedValues, defaultValue) { + for (let allowedValue of allowedValues) { + if (allowedValue == unchecked) { + return allowedValue; + } + } + // value is bad + if (typeof defaultValue == "undefined") { + throw new MalformedException("allowed_value.error", unchecked); + } + return defaultValue; + }, + + /** + * Like enum, allows only certain (string) values as input, but allows the + * caller to specify another value to return instead of the input value. E.g., + * if unchecked == "foo", return 1, if unchecked == "bar", return 2, + * otherwise throw. This allows to translate string enums into integer enums. + * + * @param unchecked {Any} The value to check + * @param mapping {Object} Associative array. property name is the input + * value, property value is the output value. E.g. the example above + * would be: { foo: 1, bar : 2 }. + * Use quotes when you need freaky characters: "baz-" : 3. + * @param defaultValue {Any} (Optional) If |unchecked| does not match + * anything in |mapping|, a |defaultValue| can be returned instead of + * throwing an exception. The latter is the default and happens when + * no |defaultValue| is passed. + * @throws MalformedException + */ + translate(unchecked, mapping, defaultValue) { + for (var inputValue in mapping) { + if (inputValue == unchecked) { + return mapping[inputValue]; + } + } + // value is bad + if (typeof defaultValue == "undefined") { + throw new MalformedException("allowed_value.error", unchecked); + } + return defaultValue; + }, +}; + +function MalformedException(msgID, uncheckedBadValue) { + var stringBundle = AccountCreationUtils.getStringBundle( + "chrome://messenger/locale/accountCreationUtil.properties" + ); + var msg = stringBundle.GetStringFromName(msgID); + if (typeof kDebug != "undefined" && kDebug) { + msg += " (bad value: " + uncheckedBadValue + ")"; + } + AccountCreationUtils.Exception.call(this, msg); +} +MalformedException.prototype = Object.create( + AccountCreationUtils.Exception.prototype +); +MalformedException.prototype.constructor = MalformedException; diff --git a/comm/mail/components/accountcreation/content/accountHub.js b/comm/mail/components/accountcreation/content/accountHub.js new file mode 100644 index 0000000000..a703ffce16 --- /dev/null +++ b/comm/mail/components/accountcreation/content/accountHub.js @@ -0,0 +1,277 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); + +/** + * Holds the main controller class. + * + * @type {?AccountHubControllerClass} + */ +var AccountHubController; + +/** + * Controller class to handle the primary views of the account setup flow. + * This class acts as a sort of controller to lazily load the needed views upon + * request. It doesn't handle any data and it should only be used to switch + * between the different setup flows. + * All methods of this class should be private, except for the open() method. + */ +class AccountHubControllerClass { + /** + * The account hub main modal dialog. + * + * @type {?HTMLElement} + */ + #modal = null; + + /** + * The currently visible view inside the dialog. + * + * @type {?HTMLElement} + */ + #currentView = null; + + /** + * Object containing all strings to trigger the needed methods for the various + * views. + */ + #accounts = { + START: () => this.#viewStart(), + MAIL: () => this.#viewEmailSetup(), + CALENDAR: () => this.#viewCalendarSetup(), + ADDRESS_BOOK: () => this.#viewAddressBookSetup(), + CHAT: () => this.#viewChatSetup(), + FEED: () => this.#viewFeedSetup(), + NNTP: () => this.#viewNNTPSetup(), + IMPORT: () => this.#viewImportSetup(), + }; + + constructor() { + this.ready = this.#init(); + } + + async #init() { + await this.#loadScript("container"); + const element = document.createElement("account-hub-container"); + document.body.appendChild(element); + this.#modal = element.modal; + + let closeButton = this.#modal.querySelector("#closeButton"); + closeButton.hidden = !MailServices.accounts.accounts.length; + closeButton.addEventListener("click", () => this.#modal.close()); + + // Listen from closing requests coming from child elements. + this.#modal.addEventListener( + "request-close", + event => { + event.stopPropagation(); + this.#modal.close(); + }, + { + capture: true, + } + ); + + this.#modal.addEventListener( + "open-view", + event => { + event.stopPropagation(); + this.open(event.detail.type); + }, + { + capture: true, + } + ); + + this.#modal.addEventListener("close", event => { + // Don't allow the dialog to be closed if some operations are can't be + // aborted or the UI can't be cleared. + if (!this.#reset()) { + event.preventDefault(); + } + }); + + this.#modal.addEventListener("cancel", event => { + if ( + !MailServices.accounts.accounts.length && + !Services.prefs.getBoolPref("app.use_without_mail_account", false) + ) { + // Prevent closing the modal if no account is currently present and the + // user didn't request using Thunderbird without an email account. + event.preventDefault(); + return; + } + + // Don't allow the dialog to be canceled via the ESC key if some + // operations are in progress and can't be aborted or the UI can't be + // cleared. + if (!this.#reset()) { + event.preventDefault(); + } + }); + } + + /** + * Check if we don't currently have the needed custom element for the + * requested view and load the needed script. We do this to avoid loading all + * the unnecessary account creation files. + * + * @param {string} view - The name of the view to load. + * @returns {Promise<void>} Resolves when custom element of the view is usable. + */ + #loadScript(view) { + if (customElements.get(`account-hub-${view}`)) { + return Promise.resolve(); + } + // eslint-disable-next-line no-unsanitized/method + return import( + `chrome://messenger/content/accountcreation/views/${view}.mjs` + ); + } + + /** + * Create a custom element and append it to the modal inner HTML, or simply + * show it if it was already loaded. + * + * @param {string} id - The ID of the template to clone. + */ + #loadView(id) { + this.#hideViews(); + + let view = this.#modal.querySelector(id); + if (view) { + view.hidden = false; + this.#currentView = view; + // Update the UI to make sure we're refreshing old views. + this.#currentView.initUI(); + return; + } + + view = document.createElement(id); + this.#modal.appendChild(view); + this.#currentView = view; + } + + /** + * Hide all the currently visible views. + */ + #hideViews() { + for (let view of this.#modal.querySelectorAll(".account-hub-view")) { + view.hidden = true; + } + } + + /** + * Open the main modal dialog and load the requested account setup view, or + * fallback to the initial start screen. + * + * @param {?string} type - Which account flow to load when the modal opens. + */ + open(type = "START") { + // Interrupt if something went wrong while cleaning up a previously loaded + // view. + if (!this.#reset()) { + return; + } + + this.#accounts[type].call(); + if (!this.#modal.open) { + this.#modal.showModal(); + } + } + + /** + * Check if we have a current class and try to trigger the rest in order to + * handle abort operations and markup clean up, if possible. + * + * @returns {boolean} - True if the reset process was successful or we didn't + * have anything to reset. + */ + #reset() { + let isClean = this.#currentView?.reset() ?? true; + // If the reset operation was successful, clear the current class. + if (isClean) { + this.#hideViews(); + this.#currentView = null; + } + return isClean; + } + + /** + * Show the initial view of the account hub dialog. + */ + async #viewStart() { + await this.#loadScript("start"); + this.#loadView("account-hub-start"); + } + + /** + * Show the email setup view. + */ + async #viewEmailSetup() { + await this.#loadScript("email"); + this.#loadView("account-hub-email"); + } + + /** + * TODO: Show the calendar setup view. + */ + #viewCalendarSetup() { + console.log("Calendar setup"); + } + + /** + * TODO: Show the address book setup view. + */ + #viewAddressBookSetup() { + console.log("Address Book setup"); + } + + /** + * TODO: Show the chat setup view. + */ + #viewChatSetup() { + console.log("Chat setup"); + } + + /** + * TODO: Show the feed setup view. + */ + #viewFeedSetup() { + console.log("Feed setup"); + } + + /** + * TODO: Show the newsgroup setup view. + */ + #viewNNTPSetup() { + console.log("Newsgroup setup"); + } + + /** + * TODO: Show the import setup view. + */ + #viewImportSetup() { + console.log("Import setup"); + } +} + +/** + * Open the account hub dialog and show the requested view. + * + * @param {?string} type - The type of view that should be loaded when the modal + * is showed. See AccountHubController::#accounts for a list references. + */ +async function openAccountHub(type) { + if (!AccountHubController) { + AccountHubController = new AccountHubControllerClass(); + } + await AccountHubController.ready; + AccountHubController.open(type); +} 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, + }); +} diff --git a/comm/mail/components/accountcreation/content/accountSetup.xhtml b/comm/mail/components/accountcreation/content/accountSetup.xhtml new file mode 100644 index 0000000000..ed1148c561 --- /dev/null +++ b/comm/mail/components/accountcreation/content/accountSetup.xhtml @@ -0,0 +1,1333 @@ +<?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/. --> + +<!DOCTYPE html> +<html + id="accountSetup" + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + windowtype="mail:accountsetup" +> + <head> + <title data-l10n-id="account-setup-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="stylesheet" href="chrome://messenger/skin/messenger.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/menulist.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/inContentDialog.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/accountSetup.css" /> + + <link rel="localization" href="branding/brand.ftl" /> + <link + rel="localization" + href="messenger/accountcreation/accountSetup.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/accountcreation/accountSetup.js" + ></script> + </head> + + <body> + <!-- Native HTML dialog used for setup cancel confirmation. --> + <dialog id="confirmExitDialog" class="account-setup-dialog"> + <div class="dialog-container vertical"> + <h2 class="dialog-title"> + <img + src="chrome://messenger/skin/icons/new/compact/info.svg" + alt="" + class="dialog-header-image" + /> + <span data-l10n-id="exit-dialog-title"></span> + </h2> + + <div class="dialog-container"> + <p + data-l10n-id="exit-dialog-description" + class="dialog-description indent" + ></p> + </div> + + <label class="toggle-container-with-text indent"> + <input + id="useWithoutAccount" + type="checkbox" + onchange="gAccountSetup.toggleExitDialogButton(event);" + /> + <span + data-l10n-id="account-setup-no-account-checkbox" + data-l10n-attrs="accesskey" + > + </span> + </label> + </div> + + <menu class="dialog-menu-container"> + <button + id="exitDialogConfirmButton" + data-l10n-id="exit-dialog-confirm-button" + ></button> + <button + id="exitDialogCancelButton" + data-l10n-id="exit-dialog-cancel-button" + class="primary" + ></button> + </menu> + </dialog> + + <!-- Native HTML dialog used for Exchange confirmation. --> + <dialog id="exchangeDialog" class="account-setup-dialog"> + <div class="dialog-container"> + <img + src="chrome://messenger/skin/icons/new/compact/question.svg" + alt="" + class="dialog-header-image" + /> + <p id="exchangeDialogQuestion" class="dialog-description"></p> + </div> + <menu class="dialog-menu-container"> + <button + id="exchangeDialogCancelButton" + data-l10n-id="exchange-dialog-cancel-button" + ></button> + <button + id="exchangeDialogConfirmButton" + data-l10n-id="exchange-dialog-confirm-button" + class="primary" + ></button> + </menu> + </dialog> + + <!-- Native HTML dialog used for insecure password confirmation. --> + <dialog id="insecureDialog" class="account-setup-dialog dialog-critical"> + <div class="dialog-container vertical"> + <h2 class="warning-title"> + <img + src="chrome://messenger/skin/icons/new/compact/warning.svg" + alt="" + class="dialog-header-image warning-icon" + /> + <span data-l10n-id="account-setup-insecure-title"></span> + </h2> + + <section + id="insecureSectionIncoming" + class="insecure-section content-blocking-category" + hidden="hidden" + > + <h3 data-l10n-id="account-setup-insecure-incoming-title"></h3> + <p id="warningIncoming"></p> + <p + id="detailsIncoming" + class="insecure-section-description indent" + ></p> + </section> + + <section + id="insecureSectionOutgoing" + class="insecure-section content-blocking-category" + hidden="hidden" + > + <h3 data-l10n-id="account-setup-insecure-outgoing-title"></h3> + <p id="warningOutgoing"></p> + <p + id="detailsOutgoing" + class="insecure-section-description indent" + ></p> + </section> + + <p + class="dialog-footnote" + data-l10n-id="account-setup-insecure-description" + > + <a + href="https://support.mozilla.org/products/thunderbird" + data-l10n-name="thunderbird-faq-link" + ></a> + </p> + </div> + + <menu class="dialog-menu-container two-columns"> + <aside> + <label class="toggle-container-with-text"> + <input + id="acknowledgeWarning" + type="checkbox" + onchange="gSecurityWarningDialog.toggleAcknowledge();" + /> + <span + data-l10n-id="account-setup-insecure-server-checkbox" + data-l10n-attrs="accesskey" + > + </span> + </label> + </aside> + + <aside> + <button + data-l10n-id="insecure-dialog-cancel-button" + onclick="gSecurityWarningDialog.onCancel();" + ></button> + <button + id="insecureConfirmButton" + data-l10n-id="insecure-dialog-confirm-button" + class="primary" + disable="disabled" + onclick="gSecurityWarningDialog.onOK();" + ></button> + </aside> + </menu> + </dialog> + + <!-- Native HTML dialog for Calendar synchronization. This is a streamlined + version of the calendar-properties-dialog.xhtml with fewer properties: + - Name + - Color + - Refresh rate + - Read only + - Show reminders + - Offline support + This dialog should be kept synced with the calendar-properties-dialog.xhtml + if one of these properties changes. + --> + <dialog id="calendarDialog" class="account-setup-dialog"> + <div class="dialog-container vertical"> + <div class="dialog-container"> + <img + src="chrome://messenger/skin/icons/new/compact/new-event.svg" + alt="" + class="dialog-header-image small" + /> + <p + data-l10n-id="calendar-dialog-title" + class="dialog-description" + ></p> + </div> + + <section class="calendar-dialog-form"> + <label + for="calendarName" + data-l10n-id="account-setup-calendar-name-label" + > + </label> + <div class="input-control"> + <input + id="calendarName" + type="text" + autocomplete="off" + class="input-field input-grow" + data-l10n-id="account-setup-calendar-name-input" + required="required" + /> + </div> + + <label + for="calendarColor" + data-l10n-id="account-setup-calendar-color-label" + > + </label> + <div class="input-control"> + <input id="calendarColor" type="color" /> + </div> + + <label + for="calendarRefresh" + data-l10n-id="account-setup-calendar-refresh-label" + > + </label> + <div class="input-control"> + <select id="calendarRefresh" class="input-grow"> + <option + data-l10n-id="account-setup-calendar-refresh-manual" + value="0" + ></option> + <option + data-l10n-id="account-setup-calendar-refresh-interval" + data-l10n-args='{ "count": 1 }' + value="1" + ></option> + <option + data-l10n-id="account-setup-calendar-refresh-interval" + data-l10n-args='{ "count": 5 }' + value="5" + ></option> + <option + data-l10n-id="account-setup-calendar-refresh-interval" + data-l10n-args='{ "count": 15 }' + value="15" + ></option> + <option + data-l10n-id="account-setup-calendar-refresh-interval" + data-l10n-args='{ "count": 30 }' + value="30" + selected="selected" + ></option> + <option + data-l10n-id="account-setup-calendar-refresh-interval" + data-l10n-args='{ "count": 60 }' + value="60" + ></option> + </select> + </div> + </section> + + <section class="indent"> + <label class="toggle-container-with-text"> + <input id="calendarReadOnly" type="checkbox" /> + <span + data-l10n-id="account-setup-calendar-read-only" + data-l10n-attrs="accesskey" + > + </span> + </label> + + <label + id="calendarShowRemindersRow" + class="toggle-container-with-text" + > + <input + id="calendarShowReminders" + type="checkbox" + checked="checked" + /> + <span + data-l10n-id="account-setup-calendar-show-reminders" + data-l10n-attrs="accesskey" + > + </span> + </label> + + <label class="toggle-container-with-text"> + <input + id="calendarOfflineSupport" + type="checkbox" + checked="checked" + /> + <span + data-l10n-id="account-setup-calendar-offline-support" + data-l10n-attrs="accesskey" + > + </span> + </label> + </section> + </div> + + <menu class="dialog-menu-container"> + <button + id="calendarDialogCancelButton" + data-l10n-id="calendar-dialog-cancel-button" + ></button> + <button + id="calendarDialogConfirmButton" + data-l10n-id="calendar-dialog-confirm-button" + class="primary" + ></button> + </menu> + </dialog> + + <header> + <h1 + id="accountSetupTitle" + data-l10n-id="account-setup-title" + class="title" + ></h1> + <p + id="accountSetupDescription" + data-l10n-id="account-setup-description" + class="description" + ></p> + <p + id="accountSetupDescriptionSecondary" + data-l10n-id="account-setup-secondary-description" + class="description" + ></p> + </header> + + <section class="main-container"> + <aside id="setupView" class="column first-column"> + <form id="form" onsubmit="gAccountSetup.onFormSubmit(event);"> + <!-- Hidden submit field to enable the natural Enter keypress to + submit the form. We do this because we have the Continue and Done + button outside the form and we want to only handle the Enter to + submit on the primary fields inside the form. --> + <input type="submit" hidden="hidden" /> + <label + for="realname" + data-l10n-id="account-setup-name-label" + data-l10n-attrs="accesskey" + > + </label> + <div class="input-control"> + <input + id="realname" + type="text" + autocomplete="off" + class="input-field" + data-l10n-id="account-setup-name-input" + oninput="gAccountSetup.onInputRealname();" + required="required" + /> + <img + id="realnameInfo" + src="chrome://messenger/skin/icons/new/compact/info.svg" + data-l10n-id="account-setup-name-info-icon" + alt="" + class="form-icon" + /> + <img + id="realnameWarning" + src="chrome://messenger/skin/icons/new/compact/warning.svg" + data-l10n-id="account-setup-name-warning-icon" + alt="" + class="form-icon icon-warning" + /> + </div> + + <label + for="email" + data-l10n-id="account-setup-email-label" + data-l10n-attrs="accesskey" + > + </label> + <div class="input-control"> + <input + id="email" + type="email" + autocomplete="off" + data-l10n-id="account-setup-email-input" + class="input-field" + oninput="gAccountSetup.onInputEmail();" + required="required" + /> + <img + id="emailInfo" + src="chrome://messenger/skin/icons/new/compact/info.svg" + data-l10n-id="account-setup-email-info-icon" + alt="" + class="form-icon" + /> + <img + id="emailWarning" + src="chrome://messenger/skin/icons/new/compact/warning.svg" + data-l10n-id="account-setup-email-warning-icon" + alt="" + class="form-icon icon-warning" + /> + </div> + + <div class="provisioner-button-container"> + <button + id="provisionerButton" + type="button" + data-l10n-id="account-provisioner-button" + data-l10n-attrs="accesskey" + class="btn-link btn-link-new-email" + onclick="openAccountProvisionerTab();" + ></button> + </div> + + <label + for="password" + data-l10n-id="account-setup-password-label" + data-l10n-attrs="accesskey" + > + </label> + <div class="input-control"> + <input + id="password" + type="password" + autocomplete="off" + class="input-field" + oninput="gAccountSetup.onInputPassword();" + /> + <button + id="passwordToggleButton" + type="button" + onclick="gAccountSetup.passwordToggle(event);" + data-l10n-id="account-setup-password-toggle-show" + class="form-toggle-button" + hidden="hidden" + > + <img + id="passwordInfo" + src="chrome://messenger/skin/icons/new/compact/hidden.svg" + class="form-icon" + alt="" + /> + </button> + </div> + + <div class="remember-button-container"> + <label class="toggle-container-with-text"> + <input id="rememberPassword" type="checkbox" checked="checked" /> + <span + data-l10n-id="account-setup-remember-password" + data-l10n-attrs="accesskey" + > + </span> + </label> + </div> + + <div id="usernameRow" hidden="hidden"> + <!-- This is only used for Exchange AutoDiscover, and even then + only when absolutely necessary and known to be needed. --> + <label + for="usernameEx" + data-l10n-id="account-setup-exchange-label" + data-l10n-attrs="accesskey" + > + </label> + <div class="input-control"> + <input + id="usernameEx" + type="text" + data-l10n-id="account-setup-exchange-input" + class="input-field" + oninput="gAccountSetup.onInputUsername();" + /> + <img + id="usernameExInfo" + src="chrome://messenger/skin/icons/new/compact/info.svg" + class="form-icon" + data-l10n-id="account-setup-exchange-info-icon" + alt="" + /> + </div> + </div> + </form> + + <section + id="accountSetupNotifications" + class="account-setup-notifications" + > + <!-- Notifications will be lazily loaded here. --> + </section> + + <!-- Results area --> + <section id="resultsArea" hidden="hidden"> + <h4 id="resultAreaTitle" class="section-title"></h4> + + <!-- IMAP --> + <div + id="resultsOption-imap" + class="content-blocking-category results-option" + > + <label class="toggle-container-with-text"> + <input + id="resultSelect-imap" + type="radio" + value="imap" + name="resultsServerType" + onchange="gAccountSetup.onResultServerTypeChanged();" + /> + <span class="strong">IMAP</span> + <p + class="result-indent" + data-l10n-id="account-setup-result-imap-description" + ></p> + </label> + <aside class="result-details"> + <section class="result-details-row"> + <img + src="chrome://messenger/skin/icons/new/compact/inbox.svg" + alt="" + /> + <div id="incomingTitle-imap" class="result-details-title"> + <h4 data-l10n-id="account-setup-incoming-title"></h4> + </div> + <div id="incomingInfo-imap" class="result-host-info"></div> + </section> + + <section class="result-details-row"> + <img + src="chrome://messenger/skin/icons/new/compact/outbox.svg" + alt="" + /> + <div id="outgoingTitle-imap" class="result-details-title"> + <h4 data-l10n-id="account-setup-outgoing-title"></h4> + </div> + <div id="outgoingInfo-imap" class="result-host-info"></div> + </section> + + <section class="result-details-row"> + <img + src="chrome://messenger/skin/icons/new/compact/contact.svg" + alt="" + /> + <div class="result-details-title"> + <h4 data-l10n-id="account-setup-username-title"></h4> + </div> + <div id="usernameInfo-imap" class="result-host-info"></div> + </section> + </aside> + </div> + + <!-- POP3 --> + <div + id="resultsOption-pop3" + class="content-blocking-category results-option" + > + <label class="toggle-container-with-text"> + <input + id="resultSelect-pop3" + type="radio" + value="pop3" + name="resultsServerType" + onchange="gAccountSetup.onResultServerTypeChanged();" + /> + <span class="strong">POP3</span> + <p + class="result-indent" + data-l10n-id="account-setup-result-pop-description" + ></p> + </label> + <aside class="result-details"> + <section class="result-details-row"> + <img + src="chrome://messenger/skin/icons/new/compact/inbox.svg" + alt="" + /> + <div id="incomingTitle-pop3" class="result-details-title"> + <h4 data-l10n-id="account-setup-incoming-title"></h4> + </div> + <div id="incomingInfo-pop3" class="result-host-info"></div> + </section> + + <section class="result-details-row"> + <img + src="chrome://messenger/skin/icons/new/compact/outbox.svg" + alt="" + /> + <div id="outgoingTitle-pop3" class="result-details-title"> + <h4 data-l10n-id="account-setup-outgoing-title"></h4> + </div> + <div id="outgoingInfo-pop3" class="result-host-info"></div> + </section> + + <section class="result-details-row"> + <img + src="chrome://messenger/skin/icons/new/compact/contact.svg" + alt="" + /> + <div class="result-details-title"> + <h4 data-l10n-id="account-setup-username-title"></h4> + </div> + <div id="usernameInfo-pop3" class="result-host-info"></div> + </section> + </aside> + </div> + + <!-- EXCHANGE --> + <div + id="resultsOption-exchange" + class="content-blocking-category results-option" + > + <label class="toggle-container-with-text"> + <input + id="resultSelect-exchange" + type="radio" + value="exchange" + name="resultsServerType" + onchange="gAccountSetup.onResultServerTypeChanged();" + /> + <span class="strong"> Exchange/Office365 </span> + <p + class="result-indent" + data-l10n-id="account-setup-result-exchange2-description" + ></p> + </label> + <aside class="result-details"> + <section class="result-details-row"> + <img + src="chrome://messenger/skin/icons/new/compact/inbox.svg" + alt="" + /> + <div id="incomingTitle-exchange" class="result-details-title"> + <h4 data-l10n-id="account-setup-exchange-title"></h4> + </div> + <div id="resultExchangeHostname" class="result-host-info"></div> + </section> + <aside id="installAddonInfo"> + <p id="resultAddonIntro"></p> + <div id="resultAddonInstallRows"></div> + </aside> + </aside> + </div> + </section> + <!-- END Results area --> + + <!-- Manual edit area --> + <section id="manualConfigArea" hidden="hidden"> + <h4 + class="section-title" + data-l10n-id="account-setup-manual-config-title" + ></h4> + + <!-- Incoming server section --> + <fieldset + class="manual-config-grid content-blocking-category" + aria-describedby="manualConfigDescription" + > + <legend + data-l10n-id="account-setup-incoming-server-legend" + ></legend> + + <!-- Incoming Protocol --> + <aside> + <label + for="incomingProtocol" + data-l10n-id="account-setup-protocol-label" + class="option-label" + > + </label> + <div class="input-control"> + <select + id="incomingProtocol" + onchange="gAccountSetup.onChangedProtocolIncoming();" + > + <option value="1">IMAP</option> + <option value="2">POP3</option> + <option + id="incomingProtocolExchange" + value="3" + hidden="hidden" + > + Exchange + </option> + </select> + </div> + </aside> + + <!-- Incoming Server --> + <aside> + <label + for="incomingHostname" + data-l10n-id="account-setup-hostname-label" + class="option-label" + > + </label> + <div class="input-control"> + <input + id="incomingHostname" + type="text" + placeholder="mail.example.com" + onchange="gAccountSetup.onChangeHostname();" + oninput="gAccountSetup.manualConfigChanged();" + class="host uri-element input-field" + /> + </div> + </aside> + + <!-- Incoming Port --> + <section> + <aside> + <label + for="incomingPort" + data-l10n-id="account-setup-port-label" + class="option-label" + > + </label> + <input + id="incomingPort" + type="number" + min="1" + max="65535" + onchange="gAccountSetup.onChangedPortIncoming();" + oninput="gAccountSetup.manualConfigChanged();" + class="input-field" + /> + </aside> + </section> + + <!-- Incoming SSL --> + <aside> + <label + for="incomingSsl" + data-l10n-id="account-setup-ssl-label" + class="option-label" + > + </label> + <div class="input-control"> + <select + id="incomingSsl" + class="security" + onchange="gAccountSetup.onChangedSSLIncoming();" + > + <!-- @see nsMsgSocketType --> + <option + data-l10n-id="ssl-autodetect-option" + value="-1" + ></option> + <option + data-l10n-id="ssl-noencryption-option" + value="0" + ></option> + <option value="2">STARTTLS</option> + <option value="3">SSL/TLS</option> + </select> + </div> + </aside> + + <!-- Incoming Authentication --> + <aside> + <label + for="incomingAuthMethod" + data-l10n-id="account-setup-auth-label" + class="option-label" + > + </label> + <div class="input-control"> + <select + id="incomingAuthMethod" + class="auth" + onchange="gAccountSetup.onChangedInAuth();" + > + <option + data-l10n-id="ssl-autodetect-option" + value="0" + ></option> + <!-- Values defined in nsMsgAuthMethod. --> + <option + data-l10n-id="ssl-cleartext-password-option" + value="3" + ></option> + <option + data-l10n-id="ssl-encrypted-password-option" + value="4" + ></option> + <option value="5">Kerberos / GSSAPI</option> + <option value="6">NTLM</option> + <option id="in-authMethod-oauth2" value="10" hidden="hidden"> + OAuth2 + </option> + </select> + </div> + </aside> + + <!-- Incoming Username --> + <aside> + <label + for="incomingUsername" + data-l10n-id="account-setup-username-label" + class="option-label" + > + </label> + <div class="input-control"> + <input + id="incomingUsername" + type="text" + data-l10n-id="account-setup-email-input" + oninput="gAccountSetup.onInputInUsername();" + class="username input-field" + /> + </div> + </aside> + </fieldset> + + <!-- Outgoing server section --> + <fieldset + class="manual-config-grid content-blocking-category" + aria-describedby="manualConfigDescription" + > + <legend + data-l10n-id="account-setup-outgoing-server-legend" + ></legend> + + <!-- Outgoing Server --> + <aside> + <label + for="outgoingHostname" + data-l10n-id="account-setup-hostname-label" + class="option-label" + > + </label> + <div class="input-control"> + <input + id="outgoingHostname" + type="text" + placeholder="mail.example.com" + onchange="gAccountSetup.onChangeHostname();" + oninput="gAccountSetup.manualConfigChanged();" + class="input-field" + /> + </div> + </aside> + + <!-- Outgoing Port --> + <section> + <aside> + <label + for="outgoingPort" + data-l10n-id="account-setup-port-label" + class="option-label" + > + </label> + <input + id="outgoingPort" + type="number" + min="1" + max="65535" + onchange="gAccountSetup.onChangedPortOutgoing();" + oninput="gAccountSetup.manualConfigChanged();" + class="input-field" + /> + </aside> + </section> + + <!-- Outgoing SSL --> + <aside> + <label + for="outgoingSsl" + data-l10n-id="account-setup-ssl-label" + class="option-label" + > + </label> + <div class="input-control"> + <select + id="outgoingSsl" + class="security" + onchange="gAccountSetup.onChangedSSLOutgoing();" + > + <!-- Values defined in nsMsgSocketType. --> + <option + data-l10n-id="ssl-autodetect-option" + value="-1" + ></option> + <option + data-l10n-id="ssl-noencryption-option" + value="0" + ></option> + <option value="2">STARTTLS</option> + <option value="3">SSL/TLS</option> + </select> + </div> + </aside> + + <!-- Outgoing Authentication --> + <aside> + <label + for="outgoingAuthMethod" + data-l10n-id="account-setup-auth-label" + class="option-label" + > + </label> + <div class="input-control"> + <select + id="outgoingAuthMethod" + class="auth" + onchange="gAccountSetup.onChangedOutAuth(event);" + > + <option + data-l10n-id="ssl-autodetect-option" + value="0" + ></option> + <!-- @see incoming --> + <option + id="outNoAuth" + data-l10n-id="ssl-no-authentication-option" + value="1" + ></option> + <option + data-l10n-id="ssl-cleartext-password-option" + value="3" + ></option> + <option + data-l10n-id="ssl-encrypted-password-option" + value="4" + ></option> + <option value="5">Kerberos / GSSAPI</option> + <option value="6">NTLM</option> + <option id="out-authMethod-oauth2" value="10" hidden="hidden"> + OAuth2 + </option> + </select> + </div> + </aside> + + <!-- Outgoing Username --> + <aside> + <label + for="outgoingUsername" + data-l10n-id="account-setup-username-label" + class="option-label" + > + </label> + <div class="input-control"> + <input + id="outgoingUsername" + type="text" + data-l10n-id="account-setup-email-input" + oninput="gAccountSetup.onInputOutUsername();" + class="username input-field" + /> + </div> + </aside> + </fieldset> + + <div class="link-row"> + <button + id="advancedSetupButton" + class="btn-link" + data-l10n-id="account-setup-advanced-setup-button" + data-l10n-attrs="accesskey" + onclick="gAccountSetup.onAdvancedSetup();" + ></button> + </div> + </section> + <!-- END Manual edit area --> + + <div class="action-buttons-container"> + <aside> + <button + id="stopButton" + type="button" + data-l10n-id="account-setup-button-stop" + data-l10n-attrs="accesskey" + onclick="gAccountSetup.onStop();" + hidden="hidden" + ></button> + <button + id="reTestButton" + type="button" + data-l10n-id="account-setup-button-retest" + data-l10n-attrs="accesskey" + onclick="gAccountSetup.testManualConfig();" + hidden="hidden" + ></button> + <button + id="manualConfigButton" + type="button" + data-l10n-id="account-setup-button-manual-config" + data-l10n-attrs="accesskey" + class="btn-link" + onclick="gAccountSetup.onManualEdit();" + hidden="hidden" + ></button> + </aside> + + <aside class="buttons-container-last"> + <button + id="cancelButton" + type="button" + data-l10n-id="account-setup-button-cancel" + data-l10n-attrs="accesskey" + onclick="gAccountSetup.onCancel();" + ></button> + <button + id="continueButton" + type="button" + data-l10n-id="account-setup-button-continue" + data-l10n-attrs="accesskey" + class="primary" + onclick="gAccountSetup.onContinue();" + disabled="disabled" + ></button> + <button + id="createButton" + type="button" + data-l10n-id="account-setup-button-done" + data-l10n-attrs="accesskey" + class="primary" + onclick="gAccountSetup.onCreate();" + hidden="hidden" + disabled="disabled" + ></button> + </aside> + </div> + + <p + id="manualConfigDescription" + data-l10n-id="account-setup-auto-description" + class="autoconfig-note tip-caption" + hidden="hidden" + ></p> + + <p + id="footDescription" + data-l10n-id="account-setup-privacy-footnote2" + class="foot-note tip-caption" + ></p> + </aside> + <!-- END first column "setupView"--> + + <aside + id="successView" + class="column first-column success-column" + hidden="hidden" + > + <section class="account-success-block"> + <img + src="chrome://messenger/skin/icons/new/compact/mail-secure.svg" + class="account-type-image" + alt="" + /> + <aside> + <span id="newAccountName" class="account-name"></span> + <span id="newAccountEmail" class="account-email"></span> + </aside> + <span id="newAccountProtocol" class="protocol-type"></span> + </section> + + <section class="quick-links"> + <button + id="settingsButton" + type="button" + data-l10n-id="account-setup-settings-button" + class="quick-link" + ></button> + <button + id="encryptionButton" + type="button" + data-l10n-id="account-setup-encryption-button" + class="quick-link" + ></button> + <button + id="signatureButton" + type="button" + data-l10n-id="account-setup-signature-button" + class="quick-link" + ></button> + <button + id="dictionariesButton" + type="button" + data-l10n-id="account-setup-dictionaries-button" + class="quick-link" + onclick="openDictionariesTab();" + ></button> + </section> + + <section id="linkedServices"> + <h3 data-l10n-id="account-setup-linked-services-title"></h3> + <p id="linkedServicesDescription" class="tip-caption"></p> + + <section id="syncNotifications" class="account-setup-notifications"> + <!-- Notifications will be lazily loaded here. --> + </section> + + <aside class="services-buttons-container"> + <section + id="linkedAddressBooks" + class="content-blocking-category linked-services-section opened" + hidden="hidden" + > + <button + type="button" + class="linked-services-button" + onclick="gAccountSetup.toggleSetupContainer(event);" + > + <aside> + <span + class="account-name" + data-l10n-id="account-setup-address-books-button" + > + </span> + <p + id="addressBooksCountDescription" + class="linked-services-description" + ></p> + </aside> + <span class="linked-service-dropdown"> + <img + src="chrome://messenger/skin/icons/new/compact/nav-right.svg" + alt="" + /> + </span> + </button> + + <div id="addressBooksSetup" class="linked-services-container"> + <ul class="linked-services-list"></ul> + <button + id="addressBooksSetupAll" + data-l10n-id="account-setup-connect-all-address-books" + class="btn-link self-center" + onclick="gAccountSetup.setupAllAddressBooks();" + hidden="hidden" + ></button> + </div> + </section> + + <section class="indent"> + <button + id="addressBookCardDAVButton" + type="button" + data-l10n-id="account-setup-address-book-carddav-button" + class="quick-link" + onclick="addNewAddressBook('CARDDAV');" + ></button> + + <button + id="addressBookLDAPButton" + type="button" + data-l10n-id="account-setup-address-book-ldap-button" + class="quick-link" + onclick="addNewAddressBook('LDAP');" + ></button> + </section> + + <section + id="linkedCalendars" + class="content-blocking-category linked-services-section opened" + hidden="hidden" + > + <button + type="button" + class="linked-services-button" + onclick="gAccountSetup.toggleSetupContainer(event);" + > + <aside> + <span + class="account-name" + data-l10n-id="account-setup-calendars-button" + > + </span> + <p + id="calendarsCountDescription" + class="linked-services-description" + ></p> + </aside> + <span class="linked-service-dropdown"> + <img + src="chrome://messenger/skin/icons/new/compact/nav-right.svg" + alt="" + /> + </span> + </button> + + <div id="calendarsSetup" class="linked-services-container"> + <ul class="linked-services-list"></ul> + <button + id="calendarsSetupAll" + data-l10n-id="account-setup-connect-all-calendars" + class="btn-link self-center" + onclick="gAccountSetup.setupAllCalendars();" + hidden="hidden" + ></button> + </div> + </section> + + <section class="indent"> + <button + id="createCalendarButton" + type="button" + data-l10n-id="account-setup-calendar-button" + class="quick-link" + onclick="showCalendarWizard();" + ></button> + </section> + </aside> + </section> + + <section class="final-buttons-container"> + <button + id="finishButton" + type="button" + data-l10n-id="account-setup-button-finish" + data-l10n-attrs="accesskey" + class="primary" + onclick="gAccountSetup.onFinish();" + ></button> + </section> + </aside> + <!-- END first column "successView"--> + + <aside class="column second-column"> + <article id="step1"> + <img + src="chrome://messenger/skin/illustrations/octopus-setup.svg" + data-l10n-id="account-setup-step1-image" + alt="" + /> + </article> + <article id="step2" hidden="hidden"> + <img + src="chrome://messenger/skin/illustrations/sloth.svg" + data-l10n-id="account-setup-step2-image" + alt="" + /> + </article> + <article id="step3" class="tip-caption" hidden="hidden"> + <img + src="chrome://messenger/skin/illustrations/form.svg" + data-l10n-id="account-setup-step3-image" + alt="" + /> + <p data-l10n-id="account-setup-selection-help"></p> + <a + href="https://support.mozilla.org/products/thunderbird/emails-thunderbird/set-up-email-thunderbird" + data-l10n-id="account-setup-documentation-help" + ></a> + - + <a + href="https://support.mozilla.org/products/thunderbird" + data-l10n-id="account-setup-forum-help" + ></a> + - + <a + href="https://www.mozilla.org/privacy/thunderbird/" + data-l10n-id="account-setup-privacy-help" + ></a> + </article> + <article id="step4" class="tip-caption" hidden="hidden"> + <img + src="chrome://messenger/skin/illustrations/connection-error.svg" + data-l10n-id="account-setup-step4-image" + alt="" + /> + <p data-l10n-id="account-setup-selection-error"></p> + <a + href="https://support.mozilla.org/products/thunderbird/emails-thunderbird/set-up-email-thunderbird" + data-l10n-id="account-setup-documentation-help" + ></a> + - + <a + href="https://support.mozilla.org/products/thunderbird" + data-l10n-id="account-setup-forum-help" + ></a> + </article> + <article id="step5" class="tip-caption" hidden="hidden"> + <img + src="chrome://messenger/skin/illustrations/accounts.svg" + data-l10n-id="account-setup-step5-image" + alt="" + /> + <p data-l10n-id="account-setup-success-help"></p> + <a + href="https://support.mozilla.org/products/thunderbird/learn-basics-get-started" + data-l10n-id="account-setup-getting-started" + ></a> + - + <a + href="https://support.mozilla.org/products/thunderbird" + data-l10n-id="account-setup-forum-help" + ></a> + - + <a + href="https://www.mozilla.org/privacy/thunderbird/" + data-l10n-id="account-setup-privacy-help" + ></a> + </article> + </aside> + <!-- END second column--> + </section> + </body> +</html> diff --git a/comm/mail/components/accountcreation/jar.mn b/comm/mail/components/accountcreation/jar.mn new file mode 100644 index 0000000000..0a3389020e --- /dev/null +++ b/comm/mail/components/accountcreation/jar.mn @@ -0,0 +1,12 @@ +# 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/accountcreation/accountHub.js (content/accountHub.js) + content/messenger/accountcreation/accountSetup.js (content/accountSetup.js) + content/messenger/accountcreation/accountSetup.xhtml (content/accountSetup.xhtml) +# Custom elements + content/messenger/accountcreation/views/container.mjs (views/container.mjs) + content/messenger/accountcreation/views/email.mjs (views/email.mjs) + content/messenger/accountcreation/views/start.mjs (views/start.mjs) diff --git a/comm/mail/components/accountcreation/moz.build b/comm/mail/components/accountcreation/moz.build new file mode 100644 index 0000000000..fa8ce3c258 --- /dev/null +++ b/comm/mail/components/accountcreation/moz.build @@ -0,0 +1,23 @@ +# 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"] + +EXTRA_JS_MODULES.accountcreation += [ + "AccountConfig.jsm", + "AccountCreationUtils.jsm", + "ConfigVerifier.jsm", + "CreateInBackend.jsm", + "ExchangeAutoDiscover.jsm", + "FetchConfig.jsm", + "FetchHTTP.jsm", + "GuessConfig.jsm", + "readFromXML.jsm", + "Sanitizer.jsm", +] + +XPCSHELL_TESTS_MANIFESTS += [ + "test/xpcshell/xpcshell.ini", +] diff --git a/comm/mail/components/accountcreation/readFromXML.jsm b/comm/mail/components/accountcreation/readFromXML.jsm new file mode 100644 index 0000000000..b853a81117 --- /dev/null +++ b/comm/mail/components/accountcreation/readFromXML.jsm @@ -0,0 +1,352 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const EXPORTED_SYMBOLS = ["readFromXML"]; + +const lazy = {}; + +ChromeUtils.defineModuleGetter( + lazy, + "AccountConfig", + "resource:///modules/accountcreation/AccountConfig.jsm" +); +ChromeUtils.defineModuleGetter( + lazy, + "AccountCreationUtils", + "resource:///modules/accountcreation/AccountCreationUtils.jsm" +); +ChromeUtils.defineModuleGetter( + lazy, + "Sanitizer", + "resource:///modules/accountcreation/Sanitizer.jsm" +); + +/* eslint-disable complexity */ +/** + * Takes an XML snipplet (as JXON) and reads the values into + * a new AccountConfig object. + * It does so securely (or tries to), by trying to avoid remote execution + * and similar holes which can appear when reading too naively. + * Of course it cannot tell whether the actual values are correct, + * e.g. it can't tell whether the host name is a good server. + * + * The XML format is documented at + * <https://wiki.mozilla.org/Thunderbird:Autoconfiguration:ConfigFileFormat> + * + * @param clientConfigXML {JXON} - The <clientConfig> node. + * @param source {String} - Used for the subSource field of AccountConfig. + * @returns AccountConfig object filled with the data from XML + */ +function readFromXML(clientConfigXML, subSource) { + function array_or_undef(value) { + return value === undefined ? [] : value; + } + var exception; + if ( + typeof clientConfigXML != "object" || + !("clientConfig" in clientConfigXML) || + !("emailProvider" in clientConfigXML.clientConfig) + ) { + dump( + `client config xml = ${JSON.stringify(clientConfigXML).substr(0, 50)} \n` + ); + let stringBundle = lazy.AccountCreationUtils.getStringBundle( + "chrome://messenger/locale/accountCreationModel.properties" + ); + throw stringBundle.GetStringFromName("no_emailProvider.error"); + } + var xml = clientConfigXML.clientConfig.emailProvider; + + var d = new lazy.AccountConfig(); + d.source = lazy.AccountConfig.kSourceXML; + d.subSource = `xml-from-${subSource}`; + + d.id = lazy.Sanitizer.hostname(xml["@id"]); + d.displayName = d.id; + try { + d.displayName = lazy.Sanitizer.label(xml.displayName); + } catch (e) { + console.error(e); + } + for (var domain of xml.$domain) { + try { + d.domains.push(lazy.Sanitizer.hostname(domain)); + } catch (e) { + console.error(e); + exception = e; + } + } + if (d.domains.length == 0) { + throw exception ? exception : "need proper <domain> in XML"; + } + exception = null; + + // incoming server + for (let iX of array_or_undef(xml.$incomingServer)) { + // input (XML) + let iO = d.createNewIncoming(); // output (object) + try { + // throws if not supported + iO.type = lazy.Sanitizer.enum(iX["@type"], [ + "pop3", + "imap", + "nntp", + "exchange", + ]); + iO.hostname = lazy.Sanitizer.hostname(iX.hostname); + iO.port = lazy.Sanitizer.integerRange(iX.port, 1, 65535); + // We need a username even for Kerberos, need it even internally. + iO.username = lazy.Sanitizer.string(iX.username); // may be a %VARIABLE% + + if ("password" in iX) { + d.rememberPassword = true; + iO.password = lazy.Sanitizer.string(iX.password); + } + + for (let iXsocketType of array_or_undef(iX.$socketType)) { + try { + iO.socketType = lazy.Sanitizer.translate(iXsocketType, { + plain: Ci.nsMsgSocketType.plain, + SSL: Ci.nsMsgSocketType.SSL, + STARTTLS: Ci.nsMsgSocketType.alwaysSTARTTLS, + }); + break; // take first that we support + } catch (e) { + exception = e; + } + } + if (iO.socketType == -1) { + throw exception ? exception : "need proper <socketType> in XML"; + } + exception = null; + + for (let iXauth of array_or_undef(iX.$authentication)) { + try { + iO.auth = lazy.Sanitizer.translate(iXauth, { + "password-cleartext": Ci.nsMsgAuthMethod.passwordCleartext, + // @deprecated TODO remove + plain: Ci.nsMsgAuthMethod.passwordCleartext, + "password-encrypted": Ci.nsMsgAuthMethod.passwordEncrypted, + // @deprecated TODO remove + secure: Ci.nsMsgAuthMethod.passwordEncrypted, + GSSAPI: Ci.nsMsgAuthMethod.GSSAPI, + NTLM: Ci.nsMsgAuthMethod.NTLM, + OAuth2: Ci.nsMsgAuthMethod.OAuth2, + }); + break; // take first that we support + } catch (e) { + exception = e; + } + } + if (!iO.auth) { + throw exception ? exception : "need proper <authentication> in XML"; + } + exception = null; + + if (iO.type == "exchange") { + try { + if ("owaURL" in iX) { + iO.owaURL = lazy.Sanitizer.url(iX.owaURL); + } + } catch (e) { + console.error(e); + } + try { + if ("ewsURL" in iX) { + iO.ewsURL = lazy.Sanitizer.url(iX.ewsURL); + } + } catch (e) { + console.error(e); + } + try { + if ("easURL" in iX) { + iO.easURL = lazy.Sanitizer.url(iX.easURL); + } + } catch (e) { + console.error(e); + } + iO.oauthSettings = { + issuer: iO.hostname, + scope: iO.owaURL || iO.ewsURL || iO.easURL, + }; + } + // defaults are in accountConfig.js + if (iO.type == "pop3" && "pop3" in iX) { + try { + if ("leaveMessagesOnServer" in iX.pop3) { + iO.leaveMessagesOnServer = lazy.Sanitizer.boolean( + iX.pop3.leaveMessagesOnServer + ); + } + if ("daysToLeaveMessagesOnServer" in iX.pop3) { + iO.daysToLeaveMessagesOnServer = lazy.Sanitizer.integer( + iX.pop3.daysToLeaveMessagesOnServer + ); + } + } catch (e) { + console.error(e); + } + try { + if ("downloadOnBiff" in iX.pop3) { + iO.downloadOnBiff = lazy.Sanitizer.boolean(iX.pop3.downloadOnBiff); + } + } catch (e) { + console.error(e); + } + } + + try { + if ("useGlobalPreferredServer" in iX) { + iO.useGlobalPreferredServer = lazy.Sanitizer.boolean( + iX.useGlobalPreferredServer + ); + } + } catch (e) { + console.error(e); + } + + // processed successfully, now add to result object + if (!d.incoming.hostname) { + // first valid + d.incoming = iO; + } else { + d.incomingAlternatives.push(iO); + } + } catch (e) { + exception = e; + } + } + if (!d.incoming.hostname) { + // throw exception for last server + throw exception ? exception : "Need proper <incomingServer> in XML file"; + } + exception = null; + + // outgoing server + for (let oX of array_or_undef(xml.$outgoingServer)) { + // input (XML) + let oO = d.createNewOutgoing(); // output (object) + try { + if (oX["@type"] != "smtp") { + let stringBundle = lazy.AccountCreationUtils.getStringBundle( + "chrome://messenger/locale/accountCreationModel.properties" + ); + throw stringBundle.GetStringFromName("outgoing_not_smtp.error"); + } + oO.hostname = lazy.Sanitizer.hostname(oX.hostname); + oO.port = lazy.Sanitizer.integerRange(oX.port, 1, 65535); + + for (let oXsocketType of array_or_undef(oX.$socketType)) { + try { + oO.socketType = lazy.Sanitizer.translate(oXsocketType, { + plain: Ci.nsMsgSocketType.plain, + SSL: Ci.nsMsgSocketType.SSL, + STARTTLS: Ci.nsMsgSocketType.alwaysSTARTTLS, + }); + break; // take first that we support + } catch (e) { + exception = e; + } + } + if (oO.socketType == -1) { + throw exception ? exception : "need proper <socketType> in XML"; + } + exception = null; + + for (let oXauth of array_or_undef(oX.$authentication)) { + try { + oO.auth = lazy.Sanitizer.translate(oXauth, { + // open relay + none: Ci.nsMsgAuthMethod.none, + // inside ISP or corp network + "client-IP-address": Ci.nsMsgAuthMethod.none, + // hope for the best + "smtp-after-pop": Ci.nsMsgAuthMethod.none, + "password-cleartext": Ci.nsMsgAuthMethod.passwordCleartext, + // @deprecated TODO remove + plain: Ci.nsMsgAuthMethod.passwordCleartext, + "password-encrypted": Ci.nsMsgAuthMethod.passwordEncrypted, + // @deprecated TODO remove + secure: Ci.nsMsgAuthMethod.passwordEncrypted, + GSSAPI: Ci.nsMsgAuthMethod.GSSAPI, + NTLM: Ci.nsMsgAuthMethod.NTLM, + OAuth2: Ci.nsMsgAuthMethod.OAuth2, + }); + + break; // take first that we support + } catch (e) { + exception = e; + } + } + if (!oO.auth) { + throw exception ? exception : "need proper <authentication> in XML"; + } + exception = null; + + if ( + "username" in oX || + // if password-based auth, we need a username, + // so go there anyways and throw. + oO.auth == Ci.nsMsgAuthMethod.passwordCleartext || + oO.auth == Ci.nsMsgAuthMethod.passwordEncrypted + ) { + oO.username = lazy.Sanitizer.string(oX.username); + } + + if ("password" in oX) { + d.rememberPassword = true; + oO.password = lazy.Sanitizer.string(oX.password); + } + + try { + // defaults are in accountConfig.js + if ("addThisServer" in oX) { + oO.addThisServer = lazy.Sanitizer.boolean(oX.addThisServer); + } + if ("useGlobalPreferredServer" in oX) { + oO.useGlobalPreferredServer = lazy.Sanitizer.boolean( + oX.useGlobalPreferredServer + ); + } + } catch (e) { + console.error(e); + } + + // processed successfully, now add to result object + if (!d.outgoing.hostname) { + // first valid + d.outgoing = oO; + } else { + d.outgoingAlternatives.push(oO); + } + } catch (e) { + console.error(e); + exception = e; + } + } + if (!d.outgoing.hostname) { + // throw exception for last server + throw exception ? exception : "Need proper <outgoingServer> in XML file"; + } + exception = null; + + d.inputFields = []; + for (let inputField of array_or_undef(xml.$inputField)) { + try { + let fieldset = { + varname: lazy.Sanitizer.alphanumdash(inputField["@key"]).toUpperCase(), + displayName: lazy.Sanitizer.label(inputField["@label"]), + exampleValue: lazy.Sanitizer.label(inputField.value), + }; + d.inputFields.push(fieldset); + } catch (e) { + console.error(e); + // For now, don't throw, + // because we don't support custom fields yet anyways. + } + } + + return d; +} +/* eslint-enable complexity */ diff --git a/comm/mail/components/accountcreation/templates/accountHubTemplate.inc.xhtml b/comm/mail/components/accountcreation/templates/accountHubTemplate.inc.xhtml new file mode 100644 index 0000000000..8b8c7c4ada --- /dev/null +++ b/comm/mail/components/accountcreation/templates/accountHubTemplate.inc.xhtml @@ -0,0 +1,158 @@ +# 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/. + +<!-- + List of all the account creation templates. Don't load any JavaScript or + CSS in here, those files are handled lazily in the controller and in the + various shadowRoots of the views. +--> +<html:template id="accountHubDialog" xmlns="http://www.w3.org/1999/xhtml"> + <dialog class="account-hub-dialog"> + <button id="closeButton" type="button"> + <img src="" alt="" /> + </button> + </dialog> +</html:template> + +<html:template id="accountHubStart" xmlns="http://www.w3.org/1999/xhtml"> + <header class="hub-header"> + <div id="welcomeHeader" class="start-header" hidden="hidden"> + <img src="chrome://branding/content/logo-gradient.svg" alt="" /> + <h1 data-l10n-id="account-hub-welcome-line"> + <span data-l10n-name="brand-name"></span> + </h1> + </div> + <div id="defaultHeader" class="start-header" hidden="hidden"> + <img src="chrome://branding/content/logo-gradient.svg" alt="" /> + <h1> + <span class="start-header-brand-name" + data-l10n-id="account-hub-brand"></span> + <span class="start-header-title" + data-l10n-id="account-hub-title"></span> + </h1> + </div> + </header> + + <div class="hub-body"> + <div class="hub-body-grid"></div> +#ifdef NIGHTLY_BUILD + <button id="hubSyncButton" + data-l10n-id="account-hub-sync-button" + class="button button-flat" + type="button" + hidden="hidden"></button> +#endif + </div> + + <footer class="hub-footer"> + <ul class="reset-list footer-links"> + <li> + <a id="hubReleaseNotes" + data-l10n-id="account-hub-release-notes"></a> + </li> + <li> + <a href="https://support.mozilla.org/products/thunderbird" + data-l10n-id="account-hub-support" + onclick="openLinkExternally(this.href);"></a> + </li> + <li> + <a href="https://give.thunderbird.net/?utm_source=thunderbird_account_hub&utm_medium=referral&utm_content=hub_footer" + data-l10n-id="account-hub-donate" + onclick="openLinkExternally(this.href);"></a> + </li> + </ul> + </footer> +</html:template> + +<html:template id="accountHubEmailSetup" xmlns="http://www.w3.org/1999/xhtml"> + <form id="emailForm" class="account-hub-form"> + <header class="hub-header"> + <h1 class="sub-view-title" data-l10n-id="account-hub-email-title"></h1> + </header> + + <div class="hub-body"> + <label for="realName" + data-l10n-id="account-setup-name-label" + data-l10n-attrs="accesskey"> + </label> + <div class="input-control"> + <input id="realName" type="text" + class="input-field" + data-l10n-id="account-setup-name-input" + required="required" /> + <img src="chrome://messenger/skin/icons/new/compact/info.svg" + data-l10n-id="account-setup-name-info-icon" + alt="" + class="form-icon" /> + <img src="chrome://messenger/skin/icons/new/compact/warning.svg" + data-l10n-id="account-setup-name-warning-icon" + alt="" + class="form-icon icon-warning" /> + </div> + + <label for="email" + data-l10n-id="account-setup-email-label" + data-l10n-attrs="accesskey"> + </label> + <div class="input-control"> + <input id="email" type="email" + data-l10n-id="account-setup-email-input" + class="input-field" + required="required" /> + <img id="emailInfo" + src="chrome://messenger/skin/icons/new/compact/info.svg" + data-l10n-id="account-setup-email-info-icon" + alt="" + class="form-icon" /> + <img id="emailWarning" + src="chrome://messenger/skin/icons/new/compact/warning.svg" + data-l10n-id="account-setup-email-warning-icon" + alt="" + class="form-icon icon-warning" /> + </div> + + <label for="password" + data-l10n-id="account-setup-password-label" + data-l10n-attrs="accesskey"> + </label> + <div class="input-control"> + <!-- Leave the placeholder empty for CSS visibility toggle of the + adjacent button. --> + <input id="password" type="password" class="input-field" + placeholder="" /> + <button id="passwordToggleButton" + type="button" + data-l10n-id="account-setup-password-toggle-show" + class="form-toggle-button" + aria-pressed="false"> + <img src="" alt="" class="form-icon" /> + </button> + </div> + + <div class="remember-button-container"> + <label class="toggle-container-with-text"> + <input id="rememberPassword" type="checkbox" checked="checked" /> + <span data-l10n-id="account-setup-remember-password" + data-l10n-attrs="accesskey"> + </span> + </label> + </div> + </div> + + <footer class="hub-footer"> + <menu class="dialog-menu-container two-columns"> + <li> + <button id="emailGoBackButton" type="button" + data-l10n-id="account-hub-email-cancel-button"></button> + </li> + <li> + <button id="emailContinueButton" type="submit" + data-l10n-id="account-hub-email-continue-button" + class="primary" + disabled="disabled"></button> + </li> + </menu> + </footer> + </form> +</html:template> diff --git a/comm/mail/components/accountcreation/test/xpcshell/data/example.com.xml b/comm/mail/components/accountcreation/test/xpcshell/data/example.com.xml new file mode 100644 index 0000000000..871ce732e2 --- /dev/null +++ b/comm/mail/components/accountcreation/test/xpcshell/data/example.com.xml @@ -0,0 +1,21 @@ +<clientConfig version="1.1"> + <emailProvider id="example.com"> + <domain>example.com</domain> + <displayName>example.com</displayName> + <displayShortName>example.com</displayShortName> + <incomingServer type="pop3"> + <hostname>pop.example.com</hostname> + <port>995</port> + <socketType>SSL</socketType> + <authentication>plain</authentication> + <username>%EMAILLOCALPART%</username> + </incomingServer> + <outgoingServer type="smtp"> + <hostname>smtp.example.com</hostname> + <port>587</port> + <socketType>STARTTLS</socketType> + <username>%EMAILADDRESS%</username> + <authentication>plain</authentication> + </outgoingServer> + </emailProvider> +</clientConfig> diff --git a/comm/mail/components/accountcreation/test/xpcshell/test_autoconfigFetchDisk.js b/comm/mail/components/accountcreation/test/xpcshell/test_autoconfigFetchDisk.js new file mode 100644 index 0000000000..5b57a42ebb --- /dev/null +++ b/comm/mail/components/accountcreation/test/xpcshell/test_autoconfigFetchDisk.js @@ -0,0 +1,76 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests getting a configuration file from the local isp directory and + * reading that file. + */ + +// Globals + +var { AccountConfig } = ChromeUtils.import( + "resource:///modules/accountcreation/AccountConfig.jsm" +); +var { FetchConfig } = ChromeUtils.import( + "resource:///modules/accountcreation/FetchConfig.jsm" +); + +var kXMLFile = "example.com.xml"; +var fetchConfigAbortable; +var copyLocation; + +function onTestSuccess(config) { + // Check that we got the expected config. + AccountConfig.replaceVariables( + config, + "Yamato Nadeshiko", + "yamato.nadeshiko@example.com", + "abc12345" + ); + + Assert.equal(config.incoming.username, "yamato.nadeshiko"); + Assert.equal(config.outgoing.username, "yamato.nadeshiko@example.com"); + Assert.equal(config.incoming.hostname, "pop.example.com"); + Assert.equal(config.outgoing.hostname, "smtp.example.com"); + Assert.equal(config.identity.realname, "Yamato Nadeshiko"); + Assert.equal(config.identity.emailAddress, "yamato.nadeshiko@example.com"); + + Assert.equal(config.subSource, "xml-from-disk"); + + do_test_finished(); +} + +function onTestFailure(e) { + do_throw(e); +} + +function run_test() { + registerCleanupFunction(finish_test); + + // Copy the xml file into place + let file = do_get_file("data/" + kXMLFile); + + copyLocation = Services.dirsvc.get("CurProcD", Ci.nsIFile); + copyLocation.append("isp"); + + file.copyTo(copyLocation, kXMLFile); + + do_test_pending(); + + // Now run the actual test + // Note we keep a global copy of this so that the abortable doesn't get + // garbage collected before the async operation has finished. + fetchConfigAbortable = FetchConfig.fromDisk( + "example.com", + onTestSuccess, + onTestFailure + ); +} + +function finish_test() { + // Remove the test config file + copyLocation.append(kXMLFile); + copyLocation.remove(false); +} diff --git a/comm/mail/components/accountcreation/test/xpcshell/test_autoconfigUtils.js b/comm/mail/components/accountcreation/test/xpcshell/test_autoconfigUtils.js new file mode 100644 index 0000000000..763084f750 --- /dev/null +++ b/comm/mail/components/accountcreation/test/xpcshell/test_autoconfigUtils.js @@ -0,0 +1,319 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* + * Tests for GuessConfig.jsm + * + * Currently tested: + * - getHostEntry function. + * - getIncomingTryOrder function. + * - getOutgoingTryOrder function. + * + * TODO: + * - Test the returned CMDS. + * - Figure out what else to test. + */ + +// Globals + +var { GuessConfig } = ChromeUtils.import( + "resource:///modules/accountcreation/GuessConfig.jsm" +); + +var { + UNKNOWN, + IMAP, + POP, + SMTP, + NONE, + STARTTLS, + SSL, + getHostEntry, + getIncomingTryOrder, + getOutgoingTryOrder, +} = GuessConfig; + +/* + * UTILITIES + */ + +function assert_equal(aA, aB, aWhy) { + if (aA != aB) { + do_throw(aWhy); + } + Assert.equal(aA, aB); +} + +/** + * Test that two host entries are the same, ignoring the commands. + */ +function assert_equal_host_entries(hostEntry, expected) { + assert_equal(hostEntry.protocol, expected[0], "Protocols are different"); + assert_equal(hostEntry.socketType, expected[1], "SSL values are different"); + assert_equal(hostEntry.port, expected[2], "Port values are different"); +} + +/** + * Assert that the list of tryOrders are the same. + */ +function assert_equal_try_orders(aA, aB) { + assert_equal(aA.length, aB.length, "tryOrders have different length"); + for (let [i, subA] of aA.entries()) { + let subB = aB[i]; + assert_equal_host_entries(subA, subB); + } +} + +/** + * Check that the POP calculations are correct for a given host and + * protocol. + */ +function checkPop(host, protocol) { + // The list of protocol+ssl+port configurations should match + // getIncomingTryOrder() in guessConfig.js. + + // port == UNKNOWN + // [POP, STARTTLS, 110], [POP, SSL, 995], [POP, NONE, 110] + // port != UNKNOWN + // ssl == UNKNOWN + // [POP, STARTTLS, port], [POP, SSL, port], [POP, NONE, port] + // ssl != UNKNOWN + // [POP, ssl, port] + let ssl = UNKNOWN; + let port = UNKNOWN; + let tryOrder = getIncomingTryOrder(host, protocol, ssl, port); + assert_equal_try_orders(tryOrder, [ + [POP, STARTTLS, 110], + [POP, SSL, 995], + [POP, NONE, 110], + ]); + + ssl = STARTTLS; + tryOrder = getIncomingTryOrder(host, protocol, ssl, port); + assert_equal_try_orders(tryOrder, [[POP, ssl, 110]]); + + ssl = SSL; + tryOrder = getIncomingTryOrder(host, protocol, ssl, port); + assert_equal_try_orders(tryOrder, [[POP, ssl, 995]]); + + ssl = NONE; + tryOrder = getIncomingTryOrder(host, protocol, ssl, port); + assert_equal_try_orders(tryOrder, [[POP, ssl, 110]]); + + ssl = UNKNOWN; + port = 31337; + tryOrder = getIncomingTryOrder(host, protocol, ssl, port); + assert_equal_try_orders(tryOrder, [ + [POP, STARTTLS, port], + [POP, SSL, port], + [POP, NONE, port], + ]); + + for (ssl in [STARTTLS, SSL, NONE]) { + tryOrder = getIncomingTryOrder(host, protocol, ssl, port); + assert_equal_try_orders(tryOrder, [[POP, ssl, port]]); + } +} + +/** + * Check that the IMAP calculations are correct for a given host and + * protocol. + */ +function checkImap(host, protocol) { + // The list of protocol+ssl+port configurations should match + // getIncomingTryOrder() in guessConfig.js. + + // port == UNKNOWN + // [IMAP, STARTTLS, 143], [IMAP, SSL, 993], [IMAP, NONE, 143] + // port != UNKNOWN + // ssl == UNKNOWN + // [IMAP, STARTTLS, port], [IMAP, SSL, port], [IMAP, NONE, port] + // ssl != UNKNOWN + // [IMAP, ssl, port]; + + let ssl = UNKNOWN; + let port = UNKNOWN; + let tryOrder = getIncomingTryOrder(host, protocol, ssl, port); + assert_equal_try_orders(tryOrder, [ + [IMAP, STARTTLS, 143], + [IMAP, SSL, 993], + [IMAP, NONE, 143], + ]); + + ssl = STARTTLS; + tryOrder = getIncomingTryOrder(host, protocol, ssl, port); + assert_equal_try_orders(tryOrder, [[IMAP, ssl, 143]]); + + ssl = SSL; + tryOrder = getIncomingTryOrder(host, protocol, ssl, port); + assert_equal_try_orders(tryOrder, [[IMAP, ssl, 993]]); + + ssl = NONE; + tryOrder = getIncomingTryOrder(host, protocol, ssl, port); + assert_equal_try_orders(tryOrder, [[IMAP, ssl, 143]]); + + ssl = UNKNOWN; + port = 31337; + tryOrder = getIncomingTryOrder(host, protocol, ssl, port); + assert_equal_try_orders(tryOrder, [ + [IMAP, STARTTLS, port], + [IMAP, SSL, port], + [IMAP, NONE, port], + ]); + + for (ssl in [STARTTLS, SSL, NONE]) { + tryOrder = getIncomingTryOrder(host, protocol, ssl, port); + assert_equal_try_orders(tryOrder, [[IMAP, ssl, port]]); + } +} + +/* + * TESTS + */ + +/** + * Test that getHostEntry returns the correct port numbers. + * + * TODO: + * - Test the returned commands as well. + */ +function test_getHostEntry() { + // IMAP port numbers. + assert_equal_host_entries(getHostEntry(IMAP, STARTTLS, UNKNOWN), [ + IMAP, + STARTTLS, + 143, + ]); + assert_equal_host_entries(getHostEntry(IMAP, SSL, UNKNOWN), [IMAP, SSL, 993]); + assert_equal_host_entries(getHostEntry(IMAP, NONE, UNKNOWN), [ + IMAP, + NONE, + 143, + ]); + + // POP port numbers. + assert_equal_host_entries(getHostEntry(POP, STARTTLS, UNKNOWN), [ + POP, + STARTTLS, + 110, + ]); + assert_equal_host_entries(getHostEntry(POP, SSL, UNKNOWN), [POP, SSL, 995]); + assert_equal_host_entries(getHostEntry(POP, NONE, UNKNOWN), [POP, NONE, 110]); + + // SMTP port numbers. + assert_equal_host_entries(getHostEntry(SMTP, STARTTLS, UNKNOWN), [ + SMTP, + STARTTLS, + 587, + ]); + assert_equal_host_entries(getHostEntry(SMTP, SSL, UNKNOWN), [SMTP, SSL, 465]); + assert_equal_host_entries(getHostEntry(SMTP, NONE, UNKNOWN), [ + SMTP, + NONE, + 587, + ]); +} + +/** + * Test the getIncomingTryOrder method. + */ +function test_getIncomingTryOrder() { + // The list of protocol+ssl+port configurations should match + // getIncomingTryOrder() in guessConfig.js. + + // protocol == POP || host starts with pop. || host starts with pop3. + checkPop("example.com", POP); + checkPop("pop.example.com", UNKNOWN); + checkPop("pop3.example.com", UNKNOWN); + checkPop("imap.example.com", POP); + + // protocol == IMAP || host starts with imap. + checkImap("example.com", IMAP); + checkImap("imap.example.com", UNKNOWN); + checkImap("pop.example.com", IMAP); + + let domain = "example.com"; + let protocol = UNKNOWN; + let ssl = UNKNOWN; + let port = UNKNOWN; + let tryOrder = getIncomingTryOrder(domain, protocol, ssl, port); + assert_equal_try_orders(tryOrder, [ + [IMAP, STARTTLS, 143], + [IMAP, SSL, 993], + [POP, STARTTLS, 110], + [POP, SSL, 995], + [IMAP, NONE, 143], + [POP, NONE, 110], + ]); + + ssl = SSL; + tryOrder = getIncomingTryOrder(domain, protocol, ssl, port); + assert_equal_try_orders(tryOrder, [ + [IMAP, SSL, 993], + [POP, SSL, 995], + ]); + + ssl = UNKNOWN; + port = 31337; + tryOrder = getIncomingTryOrder(domain, protocol, ssl, port); + assert_equal_try_orders(tryOrder, [ + [IMAP, STARTTLS, port], + [IMAP, SSL, port], + [POP, STARTTLS, port], + [POP, SSL, port], + [IMAP, NONE, port], + [POP, NONE, port], + ]); + + ssl = SSL; + tryOrder = getIncomingTryOrder(domain, protocol, ssl, port); + assert_equal_try_orders(tryOrder, [ + [IMAP, SSL, port], + [POP, SSL, port], + ]); +} + +/** + * Test the getOutgoingTryOrder method. + */ +function test_getOutgoingTryOrder() { + // The list of protocol+ssl+port configurations should match + // getOutgoingTryOrder() in guessConfig.js. + let domain = "example.com"; + let protocol = SMTP; + let ssl = UNKNOWN; + let port = UNKNOWN; + let tryOrder = getOutgoingTryOrder(domain, protocol, ssl, port); + assert_equal_try_orders(tryOrder, [ + [SMTP, STARTTLS, 587], + [SMTP, STARTTLS, 25], + [SMTP, SSL, 465], + [SMTP, NONE, 587], + [SMTP, NONE, 25], + ]); + + ssl = SSL; + tryOrder = getOutgoingTryOrder(domain, protocol, ssl, port); + assert_equal_try_orders(tryOrder, [[SMTP, SSL, 465]]); + + ssl = UNKNOWN; + port = 31337; + tryOrder = getOutgoingTryOrder(domain, protocol, ssl, port); + assert_equal_try_orders(tryOrder, [ + [SMTP, STARTTLS, port], + [SMTP, SSL, port], + [SMTP, NONE, port], + ]); + + ssl = SSL; + tryOrder = getOutgoingTryOrder(domain, protocol, ssl, port); + assert_equal_try_orders(tryOrder, [[SMTP, SSL, port]]); +} + +function run_test() { + test_getHostEntry(); + test_getIncomingTryOrder(); + test_getOutgoingTryOrder(); +} diff --git a/comm/mail/components/accountcreation/test/xpcshell/test_autoconfigXML.js b/comm/mail/components/accountcreation/test/xpcshell/test_autoconfigXML.js new file mode 100644 index 0000000000..919b0ffc2f --- /dev/null +++ b/comm/mail/components/accountcreation/test/xpcshell/test_autoconfigXML.js @@ -0,0 +1,266 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests accountcreation/readFromXML.js , reading the XML files + * containing a mail configuration. + * + * To allow forwards-compatibility (add new stuff in the future without + * breaking old clients on the new files), we are now fairly tolerant when + * reading and allow fallback mechanisms. This test checks whether that works, + * and of course also whether we can read a normal config and get the proper + * values. + */ + +// Globals + +var { AccountConfig } = ChromeUtils.import( + "resource:///modules/accountcreation/AccountConfig.jsm" +); +var { readFromXML } = ChromeUtils.import( + "resource:///modules/accountcreation/readFromXML.jsm" +); + +var { JXON } = ChromeUtils.import("resource:///modules/JXON.jsm"); + +/* + * UTILITIES + */ + +function assert_equal(aA, aB, aWhy) { + if (aA != aB) { + do_throw(aWhy); + } + Assert.equal(aA, aB); +} + +/** + * Test that two config entries are the same. + */ +function assert_equal_config(aA, aB, field) { + assert_equal(aA, aB, "Configured " + field + " is incorrect."); +} + +/* + * TESTS + */ + +/** + * Test that the xml reader returns a proper config and + * is also forwards-compatible to new additions to the data format. + */ +function test_readFromXML_config1() { + var clientConfigXML = + "<clientConfig>" + + '<emailProvider id="example.com">' + + "<domain>example.com</domain>" + + "<domain>example.net</domain>" + + "<displayName>Example</displayName>" + + "<displayShortName>Example Mail</displayShortName>" + + // 1. - protocol not supported + '<incomingServer type="imap5">' + + "<hostname>badprotocol.example.com</hostname>" + + "<port>993</port>" + + "<socketType>SSL</socketType>" + + "<username>%EMAILLOCALPART%</username>" + + "<authentication>ssl-client-cert</authentication>" + + "</incomingServer>" + + // 2. - socket type not supported + '<incomingServer type="imap">' + + "<hostname>badsocket.example.com</hostname>" + + "<port>993</port>" + + "<socketType>key-from-DNSSEC</socketType>" + + "<username>%EMAILLOCALPART%</username>" + + "<authentication>password-cleartext</authentication>" + + "</incomingServer>" + + // 3. - first supported incoming server + '<incomingServer type="imap">' + + "<hostname>imapmail.example.com</hostname>" + + "<port>993</port>" + + "<socketType>SSL</socketType>" + + "<username>%EMAILLOCALPART%</username>" + + "<authentication>password-cleartext</authentication>" + + "</incomingServer>" + + // 4. - auth method not supported + '<incomingServer type="imap">' + + "<hostname>badauth.example.com</hostname>" + + "<port>993</port>" + + "<socketType>SSL</socketType>" + + "<username>%EMAILLOCALPART%</username>" + + "<authentication>ssl-client-cert</authentication>" + + // Throw in some elements we don"t support yet + "<imap>" + + '<rootFolder path="INBOX."/>' + + '<specialFolder id="sent" path="INBOX.Sent Mail"/>' + + "</imap>" + + "</incomingServer>" + + // 5. - second supported incoming server + '<incomingServer type="pop3">' + + "<hostname>popmail.example.com</hostname>" + + // alternative hostname, not yet supported, should be ignored + "<hostname>popbackup.example.com</hostname>" + + "<port>110</port>" + + "<port>7878</port>" + + // unsupported socket type + "<socketType>GSSAPI2</socketType>" + + // but fall back + "<socketType>plain</socketType>" + + "<username>%EMAILLOCALPART%</username>" + + "<username>%EMAILADDRESS%</username>" + + // unsupported auth method + "<authentication>GSSAPI2</authentication>" + + // but fall back + "<authentication>password-encrypted</authentication>" + + "<pop3>" + + "<leaveMessagesOnServer>true</leaveMessagesOnServer>" + + "<daysToLeaveMessagesOnServer>999</daysToLeaveMessagesOnServer>" + + "</pop3>" + + "</incomingServer>" + + // outgoing server with invalid auth method + '<outgoingServer type="smtp">' + + "<hostname>badauth.example.com</hostname>" + + "<port>587</port>" + + "<socketType>STARTTLS</socketType>" + + "<username>%EMAILADDRESS%</username>" + + "<authentication>smtp-after-imap</authentication>" + + "</outgoingServer>" + + // outgoing server - supported + '<outgoingServer type="smtp">' + + "<hostname>smtpout.example.com</hostname>" + + "<hostname>smtpfallback.example.com</hostname>" + + "<port>587</port>" + + "<port>7878</port>" + + "<socketType>GSSAPI2</socketType>" + + "<socketType>STARTTLS</socketType>" + + "<username>%EMAILADDRESS%</username>" + + "<username>%EMAILLOCALPART%</username>" + + "<authentication>GSSAPI2</authentication>" + + "<authentication>client-IP-address</authentication>" + + "<smtp/>" + + "</outgoingServer>" + + // Throw in some more elements we don"t support yet + '<enableURL url="http://foobar"/>' + + '<instructionsURL url="http://foobar"/>' + + "</emailProvider>" + + "</clientConfig>"; + + var domParser = new DOMParser(); + var config = readFromXML( + JXON.build(domParser.parseFromString(clientConfigXML, "text/xml")) + ); + + Assert.equal(config instanceof AccountConfig, true); + Assert.equal("example.com", config.id); + Assert.equal("Example", config.displayName); + Assert.notEqual(-1, config.domains.indexOf("example.com")); + // 1. incoming server skipped because of an unsupported protocol + // 2. incoming server skipped because of an so-far unknown auth method + // 3. incoming server is fine for us: IMAP, SSL, cleartext password + let server = config.incoming; + Assert.equal("imapmail.example.com", server.hostname); + Assert.equal("imap", server.type); + Assert.equal(Ci.nsMsgSocketType.SSL, server.socketType); + Assert.equal(3, server.auth); // cleartext password + // only one more supported incoming server + Assert.equal(1, config.incomingAlternatives.length); + // 4. incoming server skipped because of an so-far unknown socketType + // 5. server: POP + server = config.incomingAlternatives[0]; + Assert.equal("popmail.example.com", server.hostname); + Assert.equal("pop3", server.type); + Assert.equal(Ci.nsMsgSocketType.plain, server.socketType); + Assert.equal(4, server.auth); // encrypted password + + // SMTP server, most preferred + server = config.outgoing; + Assert.equal("smtpout.example.com", server.hostname); + Assert.equal("smtp", server.type); + Assert.equal(Ci.nsMsgSocketType.alwaysSTARTTLS, server.socketType); + Assert.equal(1, server.auth); // no auth + // no other SMTP servers + Assert.equal(0, config.outgoingAlternatives.length); +} + +/** + * Test the replaceVariables method. + */ +function test_replaceVariables() { + var clientConfigXML = + "<clientConfig>" + + '<emailProvider id="example.com">' + + "<domain>example.com</domain>" + + "<displayName>example.com</displayName>" + + "<displayShortName>example.com</displayShortName>" + + '<incomingServer type="pop3">' + + "<hostname>pop.%EMAILDOMAIN%</hostname>" + + "<port>995</port>" + + "<socketType>SSL</socketType>" + + "<username>%EMAILLOCALPART%</username>" + + "<authentication>plain</authentication>" + + "<pop3>" + + "<leaveMessagesOnServer>true</leaveMessagesOnServer>" + + "<daysToLeaveMessagesOnServer>999</daysToLeaveMessagesOnServer>" + + "</pop3>" + + "</incomingServer>" + + '<outgoingServer type="smtp">' + + "<hostname>smtp.example.com</hostname>" + + "<port>587</port>" + + "<socketType>STARTTLS</socketType>" + + "<username>%EMAILADDRESS%</username>" + + "<authentication>plain</authentication>" + + "<addThisServer>true</addThisServer>" + + "<useGlobalPreferredServer>false</useGlobalPreferredServer>" + + "</outgoingServer>" + + "</emailProvider>" + + "</clientConfig>"; + + var domParser = new DOMParser(); + var config = readFromXML( + JXON.build(domParser.parseFromString(clientConfigXML, "text/xml")) + ); + + AccountConfig.replaceVariables( + config, + "Yamato Nadeshiko", + "yamato.nadeshiko@example.com", + "abc12345" + ); + + assert_equal_config( + config.incoming.username, + "yamato.nadeshiko", + "incoming server username" + ); + assert_equal_config( + config.outgoing.username, + "yamato.nadeshiko@example.com", + "outgoing server username" + ); + assert_equal_config( + config.incoming.hostname, + "pop.example.com", + "incoming server hostname" + ); + assert_equal_config( + config.outgoing.hostname, + "smtp.example.com", + "outgoing server hostname" + ); + assert_equal_config( + config.identity.realname, + "Yamato Nadeshiko", + "user real name" + ); + assert_equal_config( + config.identity.emailAddress, + "yamato.nadeshiko@example.com", + "user email address" + ); +} + +function run_test() { + test_readFromXML_config1(); + test_replaceVariables(); +} diff --git a/comm/mail/components/accountcreation/test/xpcshell/xpcshell.ini b/comm/mail/components/accountcreation/test/xpcshell/xpcshell.ini new file mode 100644 index 0000000000..8d42ab2145 --- /dev/null +++ b/comm/mail/components/accountcreation/test/xpcshell/xpcshell.ini @@ -0,0 +1,9 @@ +[DEFAULT] +head = +tail = +support-files = data/* + +[test_autoconfigFetchDisk.js] +skip-if = os == 'win' && msix # MSIX cannot write to application directory +[test_autoconfigUtils.js] +[test_autoconfigXML.js] diff --git a/comm/mail/components/accountcreation/views/container.mjs b/comm/mail/components/accountcreation/views/container.mjs new file mode 100644 index 0000000000..3bf8d9b4dc --- /dev/null +++ b/comm/mail/components/accountcreation/views/container.mjs @@ -0,0 +1,50 @@ +/* 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/. */ + +/** + * Custom Element containing the main account hub dialog. Used to append the + * needed CSS files to the shadowDom to prevent style leakage. + * NOTE: This could directly extend an HTMLDialogElement if it had a shadowRoot. + */ +class AccountHubContainer extends HTMLElement { + /** @type {HTMLDialogElement} */ + modal; + + /** @type {DOMLocalization} */ + l10n; + + connectedCallback() { + if (this.shadowRoot) { + // Already connected, no need to run it again. + return; + } + + const shadowRoot = this.attachShadow({ mode: "open" }); + + // Load styles in the shadowRoot so we don't leak it. + let style = document.createElement("link"); + style.rel = "stylesheet"; + style.href = "chrome://messenger/skin/accountHub.css"; + shadowRoot.appendChild(style); + + let template = document.getElementById("accountHubDialog"); + let clonedNode = template.content.cloneNode(true); + shadowRoot.appendChild(clonedNode); + this.modal = shadowRoot.querySelector("dialog"); + + // We need to create an internal DOM localization in order to let fluent + // see the IDs inside our shadowRoot. + this.l10n = new DOMLocalization([ + "branding/brand.ftl", + "messenger/accountcreation/accountHub.ftl", + "messenger/accountcreation/accountSetup.ftl", + ]); + this.l10n.connectRoot(shadowRoot); + } + + disconnectedCallback() { + this.l10n.disconnectRoot(this.shadowRoot); + } +} +customElements.define("account-hub-container", AccountHubContainer); diff --git a/comm/mail/components/accountcreation/views/email.mjs b/comm/mail/components/accountcreation/views/email.mjs new file mode 100644 index 0000000000..f5a4d628b7 --- /dev/null +++ b/comm/mail/components/accountcreation/views/email.mjs @@ -0,0 +1,185 @@ +/* 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/. */ + +class AccountHubEmail extends HTMLElement { + /** + * The email setup form. + * + * @type {HTMLFormElement} + */ + #form; + + /** + * The account name field. + * + * @type {HTMLInputElement} + */ + #realName; + + /** + * The email field. + * + * @type {HTMLInputElement} + */ + #email; + + /** + * The password field. + * + * @type {HTMLInputElement} + */ + #password; + + /** + * The password visibility button. + * + * @type {HTMLButtonElement} + */ + #passwordToggleButton; + + /** + * The submit form button. + * + * @type {HTMLButtonElement} + */ + #continueButton; + + /** + * The domain name extrapolated from the email address. + * + * @type {string} + */ + #domain = ""; + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.classList.add("account-hub-view"); + + let template = document.getElementById("accountHubEmailSetup"); + this.appendChild(template.content.cloneNode(true)); + + this.#form = this.querySelector("form"); + this.#realName = this.querySelector("#realName"); + this.#email = this.querySelector("#email"); + this.#password = this.querySelector("#password"); + this.#passwordToggleButton = this.querySelector("#passwordToggleButton"); + this.#continueButton = this.querySelector("#emailContinueButton"); + + this.initUI(); + + this.setupEventListeners(); + } + + /** + * Initialize the UI of the email setup flow. + */ + initUI() { + // Populate the account name if we can get some user info. + if ("@mozilla.org/userinfo;1" in Cc) { + let userInfo = Cc["@mozilla.org/userinfo;1"].getService(Ci.nsIUserInfo); + this.#realName.value = userInfo.fullname; + } + + this.#realName.focus(); + } + + /** + * Set up the event listeners for this workflow only once. + */ + setupEventListeners() { + this.#form.addEventListener("submit", event => { + event.preventDefault(); + event.stopPropagation(); + console.log("submit"); + }); + + this.#realName.addEventListener("input", () => this.#checkValidForm()); + this.#email.addEventListener("input", () => this.#checkValidForm()); + this.#password.addEventListener("input", () => this.#onPasswordInput()); + + this.#passwordToggleButton.addEventListener("click", event => { + this.#togglePasswordInput( + event.target.getAttribute("aria-pressed") === "false" + ); + }); + + // Set the Cancel/Back button. + this.querySelector("#emailGoBackButton").addEventListener("click", () => { + // If in first view, go back to start, otherwise go back in the flow. + this.dispatchEvent( + new CustomEvent("open-view", { + bubbles: true, + composed: true, + detail: { type: "START" }, + }) + ); + }); + } + + /** + * Check whether the user entered the minimum amount of information needed to + * leave the first view and is allowed to proceed to the detection step. + */ + #checkValidForm() { + const isValidForm = + this.#email.checkValidity() && this.#realName.checkValidity(); + this.#domain = isValidForm + ? this.#email.value.split("@")[1].toLowerCase() + : ""; + + this.#continueButton.disabled = !isValidForm; + } + + /** + * Handle the password visibility toggle on password input. + */ + #onPasswordInput() { + if (!this.#password.value) { + this.#togglePasswordInput(false); + } + } + + /** + * Toggle the password field type between `password` and `text` to allow users + * reading their typed password. + * + * @param {boolean} show - If the password field should become a text field. + */ + #togglePasswordInput(show) { + this.#password.type = show ? "text" : "password"; + this.#passwordToggleButton.setAttribute("aria-pressed", show.toString()); + document.l10n.setAttributes( + this.#passwordToggleButton, + show + ? "account-setup-password-toggle-hide" + : "account-setup-password-toggle-show" + ); + } + + /** + * Check if any operation is currently in process and return true only if we + * can leave this view. + * + * @returns {boolean} - If the account hub can remove this view. + */ + reset() { + // TODO + // Check for: + // - Non-abortable operations (autoconfig, email account setup, etc) + + this.#form.reset(); + this.#togglePasswordInput(false); + // TODO + // Before resetting we need to: + // - Clean up the fields. + // - Reset the autoconfig (cached server info). + // - Reset the view to the initial screen. + return true; + } +} +customElements.define("account-hub-email", AccountHubEmail); diff --git a/comm/mail/components/accountcreation/views/start.mjs b/comm/mail/components/accountcreation/views/start.mjs new file mode 100644 index 0000000000..66487cc928 --- /dev/null +++ b/comm/mail/components/accountcreation/views/start.mjs @@ -0,0 +1,163 @@ +/* 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 gSync */ + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +const { UIState } = ChromeUtils.importESModule( + "resource://services-sync/UIState.sys.mjs" +); + +class AccountHubStart extends HTMLElement { + #accounts = [ + { + id: "email", + l10n: "account-hub-email-setup-button", + type: "MAIL", + }, + { + id: "calendar", + l10n: "account-hub-calendar-setup-button", + type: "CALENDAR", + }, + { + id: "addressBook", + l10n: "account-hub-address-book-setup-button", + type: "ADDRESS_BOOK", + }, + { + id: "chat", + l10n: "account-hub-chat-setup-button", + type: "CHAT", + }, + { + id: "feed", + l10n: "account-hub-feed-setup-button", + type: "FEED", + }, + { + id: "newsgroup", + l10n: "account-hub-newsgroup-setup-button", + type: "NNTP", + }, + // TODO: Import/Export of profiles is kinda broken so we don't want to + // expose it so much for now. + // { + // id: "import", + // l10n: "account-hub-import-setup-button", + // type: "IMPORT", + // }, + ]; + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.classList.add("account-hub-view"); + + let template = document.getElementById("accountHubStart"); + this.appendChild(template.content.cloneNode(true)); + + this.initUI(); + + this.setupAccountFlows(); + } + + /** + * Update the UI to reflect reality whenever this view is triggered. + */ + initUI() { + const hasAccounts = MailServices.accounts.accounts.length; + this.querySelector("#welcomeHeader").hidden = hasAccounts; + this.querySelector("#defaultHeader").hidden = !hasAccounts; + + if (AppConstants.NIGHTLY_BUILD) { + this.updateFxAButton(); + } + + // Hide the release notes link for nightly builds since we don't have any. + if (AppConstants.NIGHTLY_BUILD) { + this.querySelector("#hubReleaseNotes").closest("li").hidden = true; + return; + } + + if ( + Services.prefs.getPrefType("app.releaseNotesURL") != + Services.prefs.PREF_INVALID + ) { + let relNotesURL = Services.urlFormatter.formatURLPref( + "app.releaseNotesURL" + ); + if (relNotesURL != "about:blank") { + this.querySelector("#hubReleaseNotes").href = relNotesURL; + return; + } + // Hide the release notes link if we don't have a URL to add. + this.querySelector("#hubReleaseNotes").closest("li").hidden = true; + } + } + + /** + * Populate the main container fo the start view with all the available + * account creation flows. + */ + setupAccountFlows() { + const fragment = new DocumentFragment(); + for (const account of this.#accounts) { + const button = document.createElement("button"); + button.id = `${account.id}Button`; + button.classList.add("button", "button-account"); + document.l10n.setAttributes(button, account.l10n); + button.addEventListener("click", () => { + this.dispatchEvent( + new CustomEvent("open-view", { + bubbles: true, + composed: true, + detail: { + type: account.type, + }, + }) + ); + }); + fragment.append(button); + } + this.querySelector(".hub-body-grid").replaceChildren(fragment); + + if (AppConstants.NIGHTLY_BUILD) { + this.querySelector("#hubSyncButton").addEventListener("click", () => { + // FIXME: Open this in a dialog or browser inside the modal, or find a + // way to close the account hub without an account and open it again in + // case the FxA login fails to set up accounts. + gSync.initFxA(); + }); + } + } + + /** + * Set up the Firefox Sync button. + */ + updateFxAButton() { + const state = UIState.get(); + this.querySelector("#hubSyncButton").hidden = + state.status == UIState.STATUS_SIGNED_IN; + } + + /** + * The start view doesn't have any abortable operation that needs to be + * checked, so we always return true. + * + * @returns {boolean} - Always true. + */ + reset() { + return true; + } +} +customElements.define("account-hub-start", AccountHubStart); |