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/mailnews/local/src/Pop3Client.jsm | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.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/mailnews/local/src/Pop3Client.jsm')
-rw-r--r-- | comm/mailnews/local/src/Pop3Client.jsm | 1570 |
1 files changed, 1570 insertions, 0 deletions
diff --git a/comm/mailnews/local/src/Pop3Client.jsm b/comm/mailnews/local/src/Pop3Client.jsm new file mode 100644 index 0000000000..59e21a66f7 --- /dev/null +++ b/comm/mailnews/local/src/Pop3Client.jsm @@ -0,0 +1,1570 @@ +/* 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 = ["Pop3Client"]; + +var { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { CommonUtils } = ChromeUtils.importESModule( + "resource://services-common/utils.sys.mjs" +); +var { CryptoUtils } = ChromeUtils.importESModule( + "resource://services-crypto/utils.sys.mjs" +); +var { LineReader } = ChromeUtils.import("resource:///modules/LineReader.jsm"); +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { MailStringUtils } = ChromeUtils.import( + "resource:///modules/MailStringUtils.jsm" +); +var { Pop3Authenticator } = ChromeUtils.import( + "resource:///modules/MailAuthenticator.jsm" +); + +/** + * A structure to represent a response received from the server. A response can + * be a single status line of a multi-line data block. + * + * @typedef {object} Pop3Response + * @property {boolean} success - True for a positive status indicator ("+OK","+"). + * @property {string} status - The status indicator, can be "+OK", "-ERR" or "+". + * @property {string} statusText - The status line of the response excluding the + * status indicator. + * @property {string} data - The part of a multi-line data block excluding the + * status line. + * + * A single char to represent a uidl status, possible values are: + * - 'k'=KEEP, + * - 'd'=DELETE + * - 'b'=TOO_BIG + * - 'f'=FETCH_BODY + * @typedef {string} UidlStatus + */ + +const UIDL_KEEP = "k"; +const UIDL_DELETE = "d"; +const UIDL_TOO_BIG = "b"; +const UIDL_FETCH_BODY = "f"; + +// There can be multiple Pop3Client running concurrently, assign each logger a +// unique prefix. +let loggerId = 0; + +function getLoggerId() { + return loggerId++ % 1000; +} + +/** + * A class to interact with POP3 server. + */ +class Pop3Client { + /** + * @param {nsIPop3IncomingServer} server - The associated server instance. + */ + constructor(server) { + this._server = server.QueryInterface(Ci.nsIMsgIncomingServer); + this._server.wrappedJSObject.runningClient = this; + this._authenticator = new Pop3Authenticator(server); + this._lineReader = new LineReader(); + + // Somehow, Services.io.newURI("pop3://localhost") doesn't work, what we + // need is just a valid nsIMsgMailNewsUrl to propagate OnStopRunningUrl and + // secInfo. + this.runningUri = Services.io + .newURI(`smtp://${this._server.hostName}:${this._server.port}`) + .mutate() + .setScheme("pop3") + .finalize() + .QueryInterface(Ci.nsIMsgMailNewsUrl); + + // A list of auth methods detected from the EHLO response. + this._supportedAuthMethods = []; + // A list of auth methods that worth a try. + this._possibleAuthMethods = []; + // Auth method set by user preference. + this._preferredAuthMethods = + { + [Ci.nsMsgAuthMethod.passwordCleartext]: ["USERPASS", "PLAIN", "LOGIN"], + [Ci.nsMsgAuthMethod.passwordEncrypted]: ["CRAM-MD5"], + [Ci.nsMsgAuthMethod.GSSAPI]: ["GSSAPI"], + [Ci.nsMsgAuthMethod.NTLM]: ["NTLM"], + [Ci.nsMsgAuthMethod.OAuth2]: ["XOAUTH2"], + [Ci.nsMsgAuthMethod.secure]: ["CRAM-MD5", "GSSAPI"], + }[server.authMethod] || []; + // The next auth method to try if the current failed. + this._nextAuthMethod = null; + + this._sink = Cc["@mozilla.org/messenger/pop3-sink;1"].createInstance( + Ci.nsIPop3Sink + ); + this._sink.popServer = server; + + this._logger = console.createInstance({ + prefix: `mailnews.pop3.${getLoggerId()}`, + maxLogLevel: "Warn", + maxLogLevelPref: "mailnews.pop3.loglevel", + }); + + this.onReady = () => {}; + + this._cutOffTimestamp = -1; + if ( + this._server.deleteByAgeFromServer && + this._server.numDaysToLeaveOnServer + ) { + // We will send DELE request for messages received before this timestamp. + this._cutOffTimestamp = + Date.now() / 1000 - this._server.numDaysToLeaveOnServer * 24 * 60 * 60; + } + + this._maxMessageSize = Infinity; + if (this._server.limitOfflineMessageSize) { + this._maxMessageSize = this._server.maxMessageSize + ? this._server.maxMessageSize * 1024 + : 50 * 1024; + } + + this._messagesToHandle = []; + } + + /** + * Initiate a connection to the server + */ + connect() { + let hostname = this._server.hostName.toLowerCase(); + this._logger.debug(`Connecting to pop://${hostname}:${this._server.port}`); + this.runningUri + .QueryInterface(Ci.nsIMsgMailNewsUrl) + .SetUrlState(true, Cr.NS_OK); + this._server.serverBusy = true; + this._secureTransport = this._server.socketType == Ci.nsMsgSocketType.SSL; + this._socket = new TCPSocket(hostname, this._server.port, { + binaryType: "arraybuffer", + useSecureTransport: this._secureTransport, + }); + this._socket.onopen = this._onOpen; + this._socket.onerror = this._onError; + + this._authenticating = false; + // Indicates if the connection has been closed and can't be used anymore. + this._destroyed = false; + // Save the incomplete server payload, start parsing after seeing \r\n. + this._pendingPayload = ""; + } + + /** + * Check and fetch new mails. + * + * @param {boolean} downloadMail - Whether to download mails using TOP/RETR. + * @param {nsIMsgWindow} msgWindow - The associated msg window. + * @param {nsIMsgFolder} folder - The folder to save the messages to. + */ + async getMail(downloadMail, msgWindow, folder) { + this._downloadMail = downloadMail; + this._msgWindow = msgWindow; + this._sink.folder = folder; + this._actionAfterAuth = this._actionStat; + this.urlListener.OnStartRunningUrl(this.runningUri, Cr.NS_OK); + + await this._loadUidlState(); + this._actionCapa(); + } + + /** + * Verify that we can logon to the server. Exit after auth success/failure. + * + * @param {nsIMsgWindow} msgWindow - The associated msg window. + */ + verifyLogon(msgWindow) { + this._msgWindow = msgWindow; + this._verifyLogon = true; + this._actionAfterAuth = this._actionDone; + this._actionCapa(); + } + + /** + * Fetch the full message of a uidl. + * + * @param {nsIPop3Sink} sink - The sink to use for this request. + * @param {string} uidl - The uidl of the message to fetch. + */ + async fetchBodyForUidl(sink, uidl) { + this._logger.debug(`Fetching body for uidl=${uidl}`); + + this._downloadMail = true; + this._sink = sink; + this._sink.buildMessageUri = true; + this.urlListener = sink.folder.QueryInterface(Ci.nsIUrlListener); + this.urlListener.OnStartRunningUrl(this.runningUri, Cr.NS_OK); + + await this._loadUidlState(); + + let uidlState = this._uidlMap.get(uidl); + if (!uidlState) { + // This uidl is no longer on the server, use this._sink to delete the + // msgHdr. + try { + this._sink.beginMailDelivery(true, null); + this._folderLocked = true; + this._logger.debug( + `Folder lock acquired uri=${this._sink.folder.URI}.` + ); + this._sink.incorporateBegin(uidl, 0); + this._actionDone(Cr.NS_ERROR_FAILURE); + } catch (e) { + this._actionError("pop3MessageWriteError"); + } + return; + } + if (uidlState.status != UIDL_TOO_BIG) { + this._actionDone(Cr.NS_ERROR_FAILURE); + return; + } + + this._singleUidlToDownload = uidl; + this._uidlMap.set(uidl, { + ...uidlState, + status: UIDL_FETCH_BODY, + }); + this._actionAfterAuth = this._actionStat; + this._actionCapa(); + } + + /** + * Mark uidl status by a passed in Map, then write to popstate.dat. + * + * @param {Map<string, UidlStatus>} uidlsToMark - A Map from uidl to status. + */ + async markMessages(uidlsToMark) { + this._logger.debug("markMessages", uidlsToMark); + if (!this._uidlMap) { + this._loadUidlState(); + } + // Callers of nsIPop3IncomingServer.markMessages (e.g. filters) expect it to + // act as a sync function, otherwise, the flags set by filters may not take + // effect. + Services.tm.spinEventLoopUntil( + "nsIPop3IncomingServer.markMessages is a synchronous function", + () => { + return this._uidlMap; + } + ); + for (let [uidl, status] of uidlsToMark) { + let uidlState = this._uidlMap.get(uidl); + this._uidlMap.set(uidl, { + ...uidlState, + status, + }); + this._uidlMapChanged = true; + } + await this._writeUidlState(true); + } + + /** + * Send `QUIT` request to the server. + * @param {Function} nextAction - Callback function after QUIT response. + */ + quit(nextAction) { + this._onData = () => {}; + this._onError = () => {}; + if (this._socket?.readyState == "open") { + this._send("QUIT"); + this._nextAction = nextAction || this.close; + } else if (nextAction) { + nextAction(); + } + } + + /** + * Close the socket. + */ + close = () => { + this._socket.close(); + }; + + /** + * The open event handler. + */ + _onOpen = () => { + this._logger.debug("Connected"); + this._socket.ondata = this._onData; + this._socket.onclose = this._onClose; + this._nextAction = res => { + // See if there is an APOP timestamp. + // eslint-disable-next-line no-control-regex + let matches = res.statusText.match(/<[\x00-\x7F]+@[\x00-\x7F]+>/); + if (matches?.[0]) { + this._apopTimestamp = matches[0]; + } + this.onOpen(); + }; + this._socket.transport.setTimeout( + Ci.nsISocketTransport.TIMEOUT_READ_WRITE, + Services.prefs.getIntPref("mailnews.tcptimeout") + ); + }; + + /** + * Parse the server response. + * + * @param {string} str - Response received from the server. + * @returns {Pop3Response} + */ + _parse(str) { + if (this._lineReader.processingMultiLineResponse) { + // When processing multi-line response, no parsing should happen. If + // `+something` is treated as status line, _actionRetrResponse will treat + // it as a new message. + return { data: str }; + } + let matches = /^(\+OK|-ERR|\+) ?(.*)\r\n([^]*)/.exec(str); + if (matches) { + let [, status, statusText, data] = matches; + return { success: status != "-ERR", status, statusText, data }; + } + return { data: str }; + } + + /** + * The data event handler. + * + * @param {TCPSocketEvent} event - The data event. + */ + _onData = async event => { + // Some servers close the socket on invalid username/password, this line + // guarantees onclose is handled before we try another AUTH method. See the + // same handling in SmtpClient.jsm. + await new Promise(resolve => setTimeout(resolve)); + + let stringPayload = CommonUtils.arrayBufferToByteString( + new Uint8Array(event.data) + ); + this._logger.debug(`S: ${stringPayload}`); + if (this._pendingPayload) { + stringPayload = this._pendingPayload + stringPayload; + } + if (stringPayload.includes("\r\n")) { + // Start parsing if the payload contains at least one line break. + this._pendingPayload = ""; + let res = this._parse(stringPayload); + this._nextAction?.(res); + } else { + // Save the incomplete payload for the next ondata event. + this._pendingPayload = stringPayload; + } + }; + + /** + * The error event handler. + * + * @param {TCPSocketErrorEvent} event - The error event. + */ + _onError = async event => { + this._logger.error(`${event.name}: a ${event.message} error occurred`); + this._server.serverBusy = false; + this.quit(); + let secInfo = + await event.target.transport?.tlsSocketControl?.asyncGetSecurityInfo(); + if (secInfo) { + this._logger.error(`SecurityError info: ${secInfo.errorCodeString}`); + if (secInfo.failedCertChain.length) { + let chain = secInfo.failedCertChain.map(c => { + return c.commonName + "; serial# " + c.serialNumber; + }); + this._logger.error(`SecurityError cert chain: ${chain.join(" <- ")}`); + } + this.runningUri.failedSecInfo = secInfo; + // Notify about the error directly. Due to the await above, the _onClose + // event is likely to complete before we get here, which means _actionDone + // ran and won't run again. + this.urlListener.OnStopRunningUrl(this.runningUri, event.errorCode); + } + this._actionDone(event.errorCode); + }; + + /** + * The close event handler. + */ + _onClose = () => { + this._logger.debug("Connection closed."); + this._server.serverBusy = false; + this._destroyed = true; + if (this._authenticating) { + // In some cases, socket is closed for invalid username/password. + this._actionAuthResponse({ success: false }); + } else { + this._actionDone(); + } + }; + + _lineSeparator = AppConstants.platform == "win" ? "\r\n" : "\n"; + + /** + * Read popstate.dat into this._uidlMap. + */ + async _loadUidlState() { + let stateFile = this._server.localPath; + stateFile.append("popstate.dat"); + if (!(await IOUtils.exists(stateFile.path))) { + this._uidlMap = new Map(); + return; + } + + let content = await IOUtils.readUTF8(stateFile.path); + this._uidlMap = new Map(); + let uidlLine = false; + for (let line of content.split(this._lineSeparator)) { + if (!line) { + continue; + } + if (uidlLine) { + let [status, uidl, receivedAt] = line.split(" "); + this._uidlMap.set(uidl, { + status, // @type {UidlStatus} + uidl, + receivedAt, + }); + } + if (line.startsWith("#")) { + // A comment line. + continue; + } + if (line.startsWith("*")) { + // The host & user line. + uidlLine = true; + } + } + } + + /** + * Write this._uidlMap into popstate.dat. + * + * @param {boolean} [resetFlag] - If true, reset _uidlMapChanged to false. + */ + async _writeUidlState(resetFlag) { + if (!this._uidlMapChanged) { + return; + } + + let stateFile = this._server.localPath; + stateFile.append("popstate.dat"); + let content = [ + "# POP3 State File", + "# This is a generated file! Do not edit.", + "", + `*${this._server.hostName} ${this._server.username}`, + ]; + for (let msg of this._messagesToHandle) { + // _messagesToHandle is not empty means an error happened, put them back + // to _uidlMap to prevent loss of popstate. + this._uidlMap.set(msg.uidl, msg); + } + for (let { status, uidl, receivedAt } of this._uidlMap.values()) { + if (receivedAt) { + content.push(`${status} ${uidl} ${receivedAt}`); + } + } + this._writeUidlPromise = IOUtils.writeUTF8( + stateFile.path, + content.join(this._lineSeparator) + ); + await this._writeUidlPromise; + this._writeUidlPromise = null; + + if (resetFlag) { + this._uidlMapChanged = false; + } + } + + /** + * Send a command to the server. + * + * @param {string} str - The command string to send. + * @param {boolean} [suppressLogging=false] - Whether to suppress logging the str. + */ + _send(str, suppressLogging) { + if (this._socket?.readyState != "open") { + if (str != "QUIT") { + this._logger.warn( + `Socket state is ${this._socket?.readyState} - won't send command.` + ); + } + return; + } + + if (suppressLogging && AppConstants.MOZ_UPDATE_CHANNEL != "default") { + this._logger.debug( + "C: Logging suppressed (it probably contained auth information)" + ); + } else { + // Do not suppress for non-release builds, so that debugging auth problems + // is easier. + this._logger.debug(`C: ${str}`); + } + + this._socket.send(CommonUtils.byteStringToArrayBuffer(str + "\r\n").buffer); + } + + /** + * Send `CAPA` request to the server. + */ + _actionCapa = () => { + this._nextAction = this._actionCapaResponse; + this._capabilities = []; + this._newMessageDownloaded = 0; + this._newMessageTotal = 0; + this._send("CAPA"); + }; + + /** + * Handle `CAPA` response. + * + * @param {Pop3Response} res - CAPA response received from the server. + */ + _actionCapaResponse = res => { + if (res.status && !res.success) { + this._actionChooseFirstAuthMethod(); + return; + } + this._lineReader.read( + res.data, + line => { + line = line.trim().toUpperCase(); + if (line == "USER") { + this._supportedAuthMethods.push("USERPASS"); + } else if (line.startsWith("SASL ")) { + this._supportedAuthMethods.push(...line.slice(5).split(" ")); + } else { + this._capabilities.push(line.split(" ")[0]); + } + }, + () => this._actionChooseFirstAuthMethod() + ); + }; + + /** + * Decide the first auth method to try. + */ + _actionChooseFirstAuthMethod = () => { + if ( + [ + Ci.nsMsgSocketType.trySTARTTLS, + Ci.nsMsgSocketType.alwaysSTARTTLS, + ].includes(this._server.socketType) && + !this._secureTransport + ) { + if (this._capabilities.includes("STLS")) { + // Init STARTTLS negotiation if required by user pref and supported. + this._nextAction = this._actionStlsResponse; + // STLS is the POP3 command to init STARTTLS. + this._send("STLS"); + } else { + // Abort if not supported. + this._logger.error("Server doesn't support STLS. Aborting."); + this._actionError("nsErrorCouldNotConnectViaTls"); + } + return; + } + + // If a preferred method is not supported by the server, no need to try it. + this._possibleAuthMethods = this._preferredAuthMethods.filter(x => + this._supportedAuthMethods.includes(x) + ); + if (!this._possibleAuthMethods.length) { + if (this._server.authMethod == Ci.nsMsgAuthMethod.passwordCleartext) { + this._possibleAuthMethods.unshift("USERPASS"); + } else if ( + this._server.authMethod == Ci.nsMsgAuthMethod.passwordEncrypted + ) { + this._possibleAuthMethods.unshift( + this._apopTimestamp ? "APOP" : "CRAM-MD5" + ); + } else if (this._server.authMethod == Ci.nsMsgAuthMethod.GSSAPI) { + this._possibleAuthMethods.unshift("GSSAPI"); + } else if (this._server.authMethod == Ci.nsMsgAuthMethod.NTLM) { + this._possibleAuthMethods.unshift("NTLM"); + } else if (this._server.authMethod == Ci.nsMsgAuthMethod.OAuth2) { + // Some servers don't return XOAUTH2 in CAPA correctly. + this._possibleAuthMethods.unshift("XOAUTH2"); + } + } + this._logger.debug(`Possible auth methods: ${this._possibleAuthMethods}`); + this._nextAuthMethod = this._nextAuthMethod || this._possibleAuthMethods[0]; + + if (this._nextAuthMethod) { + this._updateStatus("hostContact"); + this._actionAuth(); + return; + } + + // Preferred auth methods don't match any supported auth methods. Give user + // some hints to change the config. + if ( + this._server.authMethod == Ci.nsMsgAuthMethod.passwordCleartext && + this._supportedAuthMethods.includes("CRAM-MD5") + ) { + // Suggest changing from plain password to encrypted password. + this._actionError("pop3AuthChangePlainToEncrypt"); + } else if ( + this._server.authMethod == Ci.nsMsgAuthMethod.passwordEncrypted && + (this._supportedAuthMethods.includes("PLAIN") || + this._supportedAuthMethods.includes("LOGIN")) + ) { + // Suggest changing from encrypted password to plain password. + this._actionError( + this._secureTransport + ? "pop3AuthChangeEncryptToPlainSSL" + : "pop3AuthChangeEncryptToPlainNoSSL" + ); + } else { + // General suggestion about changing auth method. + this._actionError("pop3AuthMechNotSupported"); + } + }; + + /** + * Handle STLS response. STLS is the POP3 command to init STARTTLS. + * + * @param {Pop3Response} res - STLS response received from the server. + */ + _actionStlsResponse = res => { + if (!res.success) { + this._actionDone(Cr.NS_ERROR_FAILURE); + return; + } + this._socket.upgradeToSecure(); + this._secureTransport = true; + this._actionCapa(); + }; + + /** + * Init authentication depending on server capabilities and user prefs. + */ + _actionAuth = async () => { + if (!this._nextAuthMethod) { + this._actionDone(Cr.NS_ERROR_FAILURE); + return; + } + + if (this._destroyed) { + // If connection is lost, reconnect. + this.connect(); + return; + } + + this._authenticating = true; + + this._currentAuthMethod = this._nextAuthMethod; + this._nextAuthMethod = + this._possibleAuthMethods[ + this._possibleAuthMethods.indexOf(this._currentAuthMethod) + 1 + ]; + this._logger.debug(`Current auth method: ${this._currentAuthMethod}`); + this._nextAction = this._actionAuthResponse; + + switch (this._currentAuthMethod) { + case "USERPASS": + this._nextAction = this._actionAuthUserPass; + this._send(`USER ${this._authenticator.username}`); + break; + case "PLAIN": + this._nextAction = this._actionAuthPlain; + this._send("AUTH PLAIN"); + break; + case "LOGIN": + this._nextAction = this._actionAuthLoginUser; + this._send("AUTH LOGIN"); + break; + case "CRAM-MD5": + this._nextAction = this._actionAuthCramMd5; + this._send("AUTH CRAM-MD5"); + break; + case "APOP": { + let hasher = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + hasher.init(hasher.MD5); + let data = + this._apopTimestamp + + (await this._authenticator.getByteStringPassword()); + let digest = CommonUtils.bytesAsHex( + CryptoUtils.digestBytes(data, hasher) + ); + this._send(`APOP ${this._authenticator.username} ${digest}`, true); + break; + } + case "GSSAPI": { + this._authenticator.initGssapiAuth("pop"); + try { + let token = this._authenticator.getNextGssapiToken(""); + this._nextAction = res => this._actionAuthGssapi(res, token); + } catch (e) { + this._logger.error(e); + this._actionError("pop3GssapiFailure"); + return; + } + this._send("AUTH GSSAPI"); + break; + } + case "NTLM": { + this._authenticator.initNtlmAuth("pop"); + try { + let token = this._authenticator.getNextNtlmToken(""); + this._nextAction = res => this._actionAuthNtlm(res, token); + } catch (e) { + this._logger.error(e); + this._actionDone(Cr.NS_ERROR_FAILURE); + return; + } + this._send("AUTH NTLM"); + break; + } + case "XOAUTH2": + this._nextAction = this._actionAuthXoauth; + this._send("AUTH XOAUTH2"); + break; + default: + this._actionDone(); + } + }; + + /** + * Handle authentication response. + * + * @param {Pop3Response} res - Authentication response received from the server. + */ + _actionAuthResponse = res => { + this._authenticating = false; + if (res.success) { + this._actionAfterAuth(); + return; + } + + if (this._nextAuthMethod) { + // Try the next auth method. + this._actionAuth(); + return; + } + + if (this._verifyLogon) { + this.runningUri.errorCode = "pop3PasswordFailed"; + this._actionDone(Cr.NS_ERROR_FAILURE); + return; + } + + if ( + ["USERPASS", "PLAIN", "LOGIN", "CRAM-MD5"].includes( + this._currentAuthMethod + ) + ) { + this._actionError( + "pop3PasswordFailed", + [this._server.username], + res.statusText + ); + + // Ask user what to do. + let action = this._authenticator.promptAuthFailed(); + if (action == 1) { + // Cancel button pressed. + this._actionDone(Cr.NS_ERROR_FAILURE); + return; + } + if (action == 2) { + // 'New password' button pressed. + this._authenticator.forgetPassword(); + } + + // Retry. + this._nextAuthMethod = this._possibleAuthMethods[0]; + this._actionAuth(); + } else if (this._currentAuthMethod == "GSSAPI") { + this._actionError("pop3GssapiFailure", [], res.statusText); + } + }; + + /** + * The second step of USER/PASS auth, send the password to the server. + */ + _actionAuthUserPass = async res => { + if (!res.success) { + this._actionError("pop3UsernameFailure", [], res.statusText); + return; + } + this._nextAction = this._actionAuthResponse; + this._send( + `PASS ${await this._authenticator.getByteStringPassword()}`, + true + ); + }; + + /** + * The second step of PLAIN auth, send the auth token to the server. + */ + _actionAuthPlain = async res => { + if (!res.success) { + this._actionError("pop3UsernameFailure", [], res.statusText); + return; + } + this._nextAction = this._actionAuthResponse; + this._send(await this._authenticator.getPlainToken(), true); + }; + + /** + * The second step of LOGIN auth, send the username to the server. + */ + _actionAuthLoginUser = () => { + this._nextAction = this._actionAuthLoginPass; + this._logger.debug("AUTH LOGIN USER"); + this._send(btoa(this._authenticator.username), true); + }; + + /** + * The third step of LOGIN auth, send the password to the server. + */ + _actionAuthLoginPass = async res => { + if (!res.success) { + this._actionError("pop3UsernameFailure", [], res.statusText); + return; + } + this._nextAction = this._actionAuthResponse; + this._logger.debug("AUTH LOGIN PASS"); + let password = await this._authenticator.getPassword(); + if ( + !Services.prefs.getBoolPref( + "mail.smtp_login_pop3_user_pass_auth_is_latin1", + true + ) || + !/^[\x00-\xFF]+$/.test(password) // eslint-disable-line no-control-regex + ) { + // Unlike PLAIN auth, the payload of LOGIN auth is not standardized. When + // `mail.smtp_login_pop3_user_pass_auth_is_latin1` is true, we apply + // base64 encoding directly. Otherwise, we convert it to UTF-8 + // BinaryString first, to make it work with btoa(). + password = MailStringUtils.stringToByteString(password); + } + this._send(btoa(password), true); + }; + + /** + * The second step of CRAM-MD5 auth, send a HMAC-MD5 signature to the server. + * + * @param {Pop3Response} res - AUTH response received from the server. + */ + _actionAuthCramMd5 = async res => { + if (!res.success) { + this._actionError("pop3UsernameFailure", [], res.statusText); + return; + } + this._nextAction = this._actionAuthResponse; + this._send( + this._authenticator.getCramMd5Token( + await this._authenticator.getPassword(), + res.statusText + ), + true + ); + }; + + /** + * The second and next step of GSSAPI auth. + * + * @param {Pop3Response} res - AUTH response received from the server. + * @param {string} firstToken - The first GSSAPI token to send. + */ + _actionAuthGssapi = (res, firstToken) => { + if (res.status != "+") { + this._actionAuthResponse(res); + return; + } + + if (firstToken) { + this._nextAction = this._actionAuthGssapi; + this._send(firstToken, true); + return; + } + + // Server returns a challenge, we send a new token. Can happen multiple times. + let token; + try { + token = this._authenticator.getNextGssapiToken(res.statusText); + } catch (e) { + this._logger.error(e); + this._actionAuthResponse({ success: false, data: "AUTH GSSAPI" }); + return; + } + this._send(token, true); + }; + + /** + * The second and next step of NTLM auth. + * + * @param {Pop3Response} res - AUTH response received from the server. + * @param {string} firstToken - The first NTLM token to send. + */ + _actionAuthNtlm = (res, firstToken) => { + if (res.status != "+") { + this._actionAuthResponse(res); + return; + } + + if (firstToken) { + this._nextAction = this._actionAuthNtlm; + this._send(firstToken, true); + return; + } + + // Server returns a challenge, we send a new token. Can happen multiple times. + let token; + try { + token = this._authenticator.getNextNtlmToken(res.statusText); + } catch (e) { + this._logger.error(e); + this._actionAuthResponse({ success: false, data: "AUTH NTLM" }); + return; + } + this._send(token, true); + }; + + /** + * The second step of XOAUTH2 auth. + * + * @param {Pop3Response} res - AUTH response received from the server. + */ + _actionAuthXoauth = async res => { + if (res.status != "+") { + this._actionAuthResponse(res); + return; + } + this._nextAction = this._actionAuthResponse; + let token = await this._authenticator.getOAuthToken(); + this._send(token, true); + }; + + /** + * Send `STAT` request to the server. + */ + _actionStat = () => { + this._nextAction = this._actionStatResponse; + this._send("STAT"); + }; + + /** + * Handle `STAT` response. + * + * @param {Pop3Response} res - STAT response received from the server. + */ + _actionStatResponse = res => { + if (!res.success) { + this._actionError("pop3StatFail", [], res.statusText); + return; + } + + let numberOfMessages = Number.parseInt(res.statusText); + if (!numberOfMessages) { + if (this._uidlMap.size) { + this._uidlMap.clear(); + this._uidlMapChanged = true; + } + // Finish if there is no message. + MailServices.pop3.notifyDownloadCompleted(this._sink.folder, 0); + this._actionDone(); + return; + } + if (!this._downloadMail && !this._server.leaveMessagesOnServer) { + // We are not downloading new mails, so finish now. + this._sink.setBiffStateAndUpdateFE( + Ci.nsIMsgFolder.nsMsgBiffState_NewMail, + numberOfMessages, + true + ); + this._actionDone(); + return; + } + + if (this._downloadMail) { + try { + this._sink.beginMailDelivery( + this._singleUidlToDownload, + this._msgWindow + ); + this._folderLocked = true; + this._logger.debug( + `Folder lock acquired uri=${this._sink.folder.URI}.` + ); + } catch (e) { + const NS_MSG_FOLDER_BUSY = 2153054218; + if (e.result == NS_MSG_FOLDER_BUSY) { + this._actionError("pop3ServerBusy", [this._server.prettyName]); + } else { + this._actionError("pop3MessageWriteError"); + } + return; + } + } + this._actionList(); + }; + + /** + * Send `LIST` request to the server. + */ + _actionList = () => { + this._messageSizeMap = new Map(); + this._nextAction = this._actionListResponse; + this._send("LIST"); + }; + + /** + * Handle `LIST` response. + * + * @param {Pop3Response} res - LIST response received from the server. + */ + _actionListResponse = res => { + if (res.status && !res.success) { + this._actionError("pop3ListFailure", [], res.statusText); + return; + } + this._lineReader.read( + res.data, + line => { + let [messageNumber, messageSize] = line.split(" "); + this._messageSizeMap.set(messageNumber, Number(messageSize)); + }, + () => { + this._actionUidl(); + } + ); + }; + + /** + * Send `UIDL` request to the server. + */ + _actionUidl = () => { + this._messagesToHandle = []; + this._newUidlMap = new Map(); + this._nextAction = this._actionUidlResponse; + this._send("UIDL"); + }; + + /** + * Handle `UIDL` response. + * + * @param {Pop3Response} res - UIDL response received from the server. + */ + _actionUidlResponse = ({ status, success, data }) => { + if (status && !success) { + this._actionNoUidl(); + return; + } + this._lineReader.read( + data, + line => { + let [messageNumber, uidl] = line.split(" "); + uidl = uidl.trim(); + let uidlState = this._uidlMap.get(uidl); + if (uidlState) { + if ( + uidlState.status == UIDL_KEEP && + (!this._server.leaveMessagesOnServer || + uidlState.receivedAt < this._cutOffTimestamp) + ) { + // Delete this message. + this._messagesToHandle.push({ + ...uidlState, + messageNumber, + status: UIDL_DELETE, + }); + } else if ( + [UIDL_FETCH_BODY, UIDL_DELETE].includes(uidlState.status) + ) { + // Fetch the full message. + this._messagesToHandle.push({ + ...uidlState, + messageNumber, + status: uidlState.status, + }); + } else { + // Do nothing to this message. + this._newUidlMap.set(uidl, uidlState); + } + } else { + this._newMessageTotal++; + // Fetch the full message or only headers depending on server settings + // and message size. + let status = + this._server.headersOnly || + this._messageSizeMap.get(messageNumber) > this._maxMessageSize + ? UIDL_TOO_BIG + : UIDL_FETCH_BODY; + this._messagesToHandle.push({ + messageNumber, + uidl, + status, + }); + } + }, + () => { + if (!this._downloadMail) { + let numberOfMessages = this._messagesToHandle.filter( + // No receivedAt means we're seeing it for the first time. + msg => !msg.receivedAt + ).length; + if (numberOfMessages) { + this._sink.setBiffStateAndUpdateFE( + Ci.nsIMsgFolder.nsMsgBiffState_NewMail, + numberOfMessages, + true + ); + } + this._actionDone(); + return; + } + + if (this._singleUidlToDownload) { + this._messagesToHandle = this._messagesToHandle.filter( + msg => msg.uidl == this._singleUidlToDownload + ); + this._newUidlMap = this._uidlMap; + } + + this._messagesToDownload = this._messagesToHandle.filter(msg => + [UIDL_FETCH_BODY, UIDL_TOO_BIG].includes(msg.status) + ); + this._totalDownloadSize = this._messagesToDownload.reduce( + (acc, msg) => acc + this._messageSizeMap.get(msg.messageNumber), + 0 + ); + this._totalReceivedSize = 0; + try { + let localFolder = this._sink.folder.QueryInterface( + Ci.nsIMsgLocalMailFolder + ); + if ( + localFolder.warnIfLocalFileTooBig( + this._msgWindow, + this._totalDownloadSize + ) + ) { + throw new Error("Not enough disk space"); + } + } catch (e) { + this._logger.error(e); + this._actionDone(Cr.NS_ERROR_FAILURE); + return; + } + + this._uidlMapChanged = + this._uidlMap.size != this._newUidlMap.size || + this._messagesToHandle.length; + // This discards staled uidls that are no longer on the server. + this._uidlMap = this._newUidlMap; + + this._sink.setMsgsToDownload(this._messagesToDownload.length); + this._actionHandleMessage(); + this._updateProgress(); + } + ); + }; + + /** + * If the server doesn't support UIDL, leaveMessagesOnServer and headersOnly + * feature can't be used. + */ + _actionNoUidl = () => { + if ( + this._server.leaveMessagesOnServer || + this._server.headersOnly || + this._server.limitOfflineMessageSize || + this._singleUidlToDownload + ) { + this._actionError("pop3ServerDoesNotSupportUidlEtc", [ + this._server.hostName, + ]); + return; + } + for (let [messageNumber] of this._messageSizeMap) { + // Send RETR for each message. + this._messagesToHandle.push({ + status: UIDL_FETCH_BODY, + messageNumber, + }); + } + this._actionHandleMessage(); + }; + + /** + * Consume a message from this._messagesToHandle, decide to send TOP, RETR or + * DELE request. + */ + _actionHandleMessage = () => { + this._currentMessage = this._messagesToHandle.shift(); + if ( + this._messagesToHandle.length > 0 && + this._messagesToHandle.length % 20 == 0 && + !this._writeUidlPromise + ) { + // Update popstate.dat every 20 messages, so that even if an error + // happens, no need to re-download all messages. + this._writeUidlState(); + } + if (this._currentMessage) { + switch (this._currentMessage.status) { + case UIDL_TOO_BIG: + if (this._topFailed) { + this._actionRetr(); + } else { + this._actionTop(); + } + break; + case UIDL_FETCH_BODY: + this._actionRetr(); + break; + case UIDL_DELETE: + this._actionDelete(); + break; + default: + break; + } + } else { + this._sink.setBiffStateAndUpdateFE( + Ci.nsIMsgFolder.nsMsgBiffState_NewMail, + this._messagesToDownload + ? this._messagesToDownload.length + : // No UIDL support, every message is new. + this._messageSizeMap.size, + false + ); + try { + this._sink.endMailDelivery(this); + this._folderLocked = false; + this._logger.debug("Folder lock released."); + } catch (e) { + this._logger.error("endMailDelivery failed", e); + this._actionDone(e.result || Cr.NS_ERROR_FAILURE); + return; + } + this._actionDone(); + } + }; + + /** + * Send `TOP` request to the server. + */ + _actionTop = () => { + this._nextAction = this._actionTopResponse; + let lineNumber = this._server.headersOnly ? 0 : 20; + this._send(`TOP ${this._currentMessage.messageNumber} ${lineNumber}`); + this._updateStatus("receivingMessages", [ + ++this._newMessageDownloaded, + this._newMessageTotal, + ]); + }; + + /** + * Handle `TOP` response. + * + * @param {Pop3Response} res - TOP response received from the server. + */ + _actionTopResponse = res => { + if (res.status) { + if (res.success) { + try { + // Call incorporateBegin only once for each message. + this._sink.incorporateBegin( + this._currentMessage.uidl, + Ci.nsMsgMessageFlags.Partial + ); + } catch (e) { + this._actionError("pop3MessageWriteError"); + return; + } + } else { + // TOP is not supported. + this._topFailed = true; + this._actionRetr(); + return; + } + } + this._lineReader.read( + res.data, + line => { + // Remove \r\n and use the OS native line ending. + line = line.slice(0, -2) + this._lineSeparator; + try { + this._sink.incorporateWrite(line, line.length); + } catch (e) { + this._actionError("pop3MessageWriteError"); + throw e; // Stop reading. + } + }, + () => { + try { + this._sink.incorporateComplete( + this._msgWindow, + // Set size because it's a partial message. + this._messageSizeMap.get(this._currentMessage.messageNumber) + ); + } catch (e) { + this._actionError("pop3MessageWriteError"); + return; + } + + let state = this._uidlMap.get(this._currentMessage.uidl); + if (state?.status == UIDL_FETCH_BODY) { + this._actionRetr(); + return; + } + if (state?.status == UIDL_DELETE) { + this._actionDelete(); + return; + } + this._uidlMap.set(this._currentMessage.uidl, { + status: UIDL_TOO_BIG, + uidl: this._currentMessage.uidl, + receivedAt: Math.floor(Date.now() / 1000), + }); + this._uidlMapChanged = true; + this._actionHandleMessage(); + } + ); + }; + + /** + * Send `RETR` request to the server. + */ + _actionRetr = () => { + this._nextAction = this._actionRetrResponse; + this._send(`RETR ${this._currentMessage.messageNumber}`); + this._updateStatus("receivingMessages", [ + ++this._newMessageDownloaded, + this._newMessageTotal, + ]); + }; + + /** + * Handle `RETR` response. + * + * @param {Pop3Response} res - RETR response received from the server. + */ + _actionRetrResponse = res => { + if (res.status) { + if (!res.success) { + this._actionError("pop3RetrFailure", [], res.statusText); + return; + } + try { + // Call incorporateBegin only once for each message. + this._sink.incorporateBegin(this._currentMessage.uidl, 0); + } catch (e) { + this._actionError("pop3MessageWriteError"); + return; + } + } + this._lineReader.read( + res.data, + line => { + line = line.slice(0, -2) + this._lineSeparator; + try { + this._sink.incorporateWrite(line, line.length); + } catch (e) { + this._actionError("pop3MessageWriteError"); + throw e; // Stop reading. + } + }, + () => { + // Don't count the ending indicator. + this._totalReceivedSize -= ".\r\n".length; + try { + this._sink.incorporateComplete( + this._msgWindow, + 0 // Set size only when it's a partial message. + ); + } catch (e) { + this._actionError("pop3MessageWriteError"); + return; + } + if (this._server.leaveMessagesOnServer) { + let state = this._uidlMap.get(this._currentMessage.uidl); + if (state?.status == UIDL_DELETE) { + this._actionDelete(); + } else { + this._uidlMap.set(this._currentMessage.uidl, { + status: UIDL_KEEP, + uidl: this._currentMessage.uidl, + receivedAt: Math.floor(Date.now() / 1000), + }); + this._uidlMapChanged = true; + this._actionHandleMessage(); + } + } else { + this._actionDelete(); + } + } + ); + + this._totalReceivedSize += res.data.length; + this._updateProgress(); + }; + + /** + * Send `DELE` request to the server. + */ + _actionDelete = () => { + this._nextAction = this._actionDeleteResponse; + this._send(`DELE ${this._currentMessage.messageNumber}`); + }; + + /** + * Handle `DELE` response. + * + * @param {Pop3Response} res - DELE response received from the server. + */ + _actionDeleteResponse = res => { + if (!res.success) { + this._actionError("pop3DeleFailure", [], res.statusText); + return; + } + this._actionHandleMessage(); + }; + + /** + * Show an error prompt. + * + * @param {string} errorName - An error name corresponds to an entry of + * localMsgs.properties. + * @param {string[]} errorParams - Params to construct the error message. + * @param {string} serverErrorMsg - Error message returned by the server. + */ + _actionError(errorName, errorParams, serverErrorMsg) { + this._logger.error(`Got an error name=${errorName}`); + if (errorName != "pop3PasswordFailed") { + this._actionDone(Cr.NS_ERROR_FAILURE); + } + + if (!this._msgWindow) { + return; + } + let bundle = Services.strings.createBundle( + "chrome://messenger/locale/localMsgs.properties" + ); + let errorMsg; + if (errorParams) { + errorMsg = bundle.formatStringFromName(errorName, errorParams); + } else { + errorMsg = bundle.GetStringFromName(errorName); + } + if (serverErrorMsg) { + let serverSaidPrefix = bundle.formatStringFromName("pop3ServerSaid", [ + this._server.hostName, + ]); + errorMsg += ` ${serverSaidPrefix} ${serverErrorMsg}`; + } + + let errorTitle = bundle.formatStringFromName("pop3ErrorDialogTitle", [ + this._server.prettyName, + ]); + Services.prompt.alert(this._msgWindow.domWindow, errorTitle, errorMsg); + } + + /** + * Save popstate.dat when necessary, send QUIT. + * @param {nsresult} status - Indicate if the last action succeeded. + */ + _actionDone = (status = Cr.NS_OK) => { + if (this._done) { + return; + } + this._done = true; + this._logger.debug(`Done with status=${status}`); + this._authenticating = false; + if (status == Cr.NS_OK) { + if (this._newMessageTotal) { + this._updateStatus("receivedMsgs", [ + this._newMessageTotal, + this._newMessageTotal, + ]); + } else { + this._updateStatus("noNewMessages"); + } + } else if (this._currentMessage) { + // Put _currentMessage back to the queue to prevent loss of popstate. + this._messagesToHandle.unshift(this._currentMessage); + } + this._writeUidlState(true); + // Normally we clean up after QUIT response. + this.quit(() => this._cleanUp(status)); + // If we didn't receive QUIT response after 3 seconds, clean up anyway. + setTimeout(() => { + if (!this._cleanedUp) { + this._cleanUp(status); + } + }, 3000); + }; + + /** + * Notify listeners, close the socket and rest states. + * @param {nsresult} status - Indicate if the last action succeeded. + */ + _cleanUp = status => { + this._cleanedUp = true; + this.close(); + this.urlListener.OnStopRunningUrl(this.runningUri, status); + this.runningUri.SetUrlState(false, Cr.NS_OK); + this.onDone?.(status); + if (this._folderLocked) { + this._sink.abortMailDelivery(this); + this._folderLocked = false; + this._logger.debug("Folder lock released."); + } + this._server.wrappedJSObject.runningClient = null; + }; + + /** + * Show a status message in the status bar. + * + * @param {string} statusName - A string name in localMsgs.properties. + * @param {string[]} [params] - Params to format the string. + */ + _updateStatus(statusName, params) { + if (!this._msgWindow?.statusFeedback) { + return; + } + if (!this._localBundle) { + this._localBundle = Services.strings.createBundle( + "chrome://messenger/locale/localMsgs.properties" + ); + this._messengerBundle = Services.strings.createBundle( + "chrome://messenger/locale/messenger.properties" + ); + } + let status = params + ? this._localBundle.formatStringFromName(statusName, params) + : this._localBundle.GetStringFromName(statusName); + this._msgWindow.statusFeedback.showStatusString( + this._messengerBundle.formatStringFromName("statusMessage", [ + this._server.prettyName, + status, + ]) + ); + } + + /** + * Show a progress bar in the status bar. + */ + _updateProgress() { + this._msgWindow?.statusFeedback?.showProgress( + Math.floor((this._totalReceivedSize * 100) / this._totalDownloadSize) + ); + } + + /** @see nsIPop3Protocol */ + checkMessage(uidl) { + return this._uidlMap.has(uidl); + } +} |