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/compose/src/SmtpClient.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/compose/src/SmtpClient.jsm')
-rw-r--r-- | comm/mailnews/compose/src/SmtpClient.jsm | 1344 |
1 files changed, 1344 insertions, 0 deletions
diff --git a/comm/mailnews/compose/src/SmtpClient.jsm b/comm/mailnews/compose/src/SmtpClient.jsm new file mode 100644 index 0000000000..2eb13985e3 --- /dev/null +++ b/comm/mailnews/compose/src/SmtpClient.jsm @@ -0,0 +1,1344 @@ +/* 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/. + * + * Based on https://github.com/emailjs/emailjs-smtp-client + * + * Copyright (c) 2013 Andris Reinman + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +const EXPORTED_SYMBOLS = ["SmtpClient"]; + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); +var { MailStringUtils } = ChromeUtils.import( + "resource:///modules/MailStringUtils.jsm" +); +var { SmtpAuthenticator } = ChromeUtils.import( + "resource:///modules/MailAuthenticator.jsm" +); +var { MsgUtils } = ChromeUtils.import( + "resource:///modules/MimeMessageUtils.jsm" +); + +class SmtpClient { + /** + * The number of RCPT TO commands sent on the connection by this client. + * This can count-up over multiple messages. + */ + rcptCount = 0; + + /** + * Set true only when doing a retry. + */ + isRetry = false; + + /** + * Creates a connection object to a SMTP server and allows to send mail through it. + * Call `connect` method to inititate the actual connection, the constructor only + * defines the properties but does not actually connect. + * + * @class + * + * @param {nsISmtpServer} server - The associated nsISmtpServer instance. + */ + constructor(server) { + this.options = { + alwaysSTARTTLS: + server.socketType == Ci.nsMsgSocketType.trySTARTTLS || + server.socketType == Ci.nsMsgSocketType.alwaysSTARTTLS, + requireTLS: server.socketType == Ci.nsMsgSocketType.SSL, + }; + + this.socket = false; // Downstream TCP socket to the SMTP server, created with TCPSocket + this.waitDrain = false; // Keeps track if the downstream socket is currently full and a drain event should be waited for or not + + // Private properties + + this._server = server; + this._authenticator = new SmtpAuthenticator(server); + this._authenticating = false; + // 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]: ["PLAIN", "LOGIN"], + [Ci.nsMsgAuthMethod.passwordEncrypted]: ["CRAM-MD5"], + [Ci.nsMsgAuthMethod.GSSAPI]: ["GSSAPI"], + [Ci.nsMsgAuthMethod.NTLM]: ["NTLM"], + [Ci.nsMsgAuthMethod.OAuth2]: ["XOAUTH2"], + [Ci.nsMsgAuthMethod.secure]: ["CRAM-MD5", "XOAUTH2"], + }[server.authMethod] || []; + // The next auth method to try if the current failed. + this._nextAuthMethod = null; + + // A list of capabilities detected from the EHLO response. + this._capabilities = []; + + this._dataMode = false; // If true, accepts data from the upstream to be passed directly to the downstream socket. Used after the DATA command + this._lastDataBytes = ""; // Keep track of the last bytes to see how the terminating dot should be placed + this._envelope = null; // Envelope object for tracking who is sending mail to whom + this._currentAction = null; // Stores the function that should be run after a response has been received from the server + + this._parseBlock = { data: [], statusCode: null }; + this._parseRemainder = ""; // If the complete line is not received yet, contains the beginning of it + + this.logger = MsgUtils.smtpLogger; + + // Event placeholders + this.onerror = (e, failedSecInfo) => {}; // Will be run when an error occurs. The `onclose` event will fire subsequently. + this.ondrain = () => {}; // More data can be buffered in the socket. + this.onclose = () => {}; // The connection to the server has been closed + this.onidle = () => {}; // The connection is established and idle, you can send mail now + this.onready = failedRecipients => {}; // Waiting for mail body, lists addresses that were not accepted as recipients + this.ondone = success => {}; // The mail has been sent. Wait for `onidle` next. Indicates if the message was queued by the server. + // Callback when this client is ready to be reused. + this.onFree = () => {}; + } + + /** + * Initiate a connection to the server + */ + connect() { + if (this.socket?.readyState == "open") { + this.logger.debug("Reusing a connection"); + this.onidle(); + } else { + let hostname = this._server.hostname.toLowerCase(); + let port = this._server.port || (this.options.requireTLS ? 465 : 587); + this.logger.debug(`Connecting to smtp://${hostname}:${port}`); + this._secureTransport = this.options.requireTLS; + this.socket = new TCPSocket(hostname, port, { + binaryType: "arraybuffer", + useSecureTransport: this._secureTransport, + }); + + this.socket.onerror = this._onError; + this.socket.onopen = this._onOpen; + } + this._freed = false; + } + + /** + * Sends QUIT + */ + quit() { + this._authenticating = false; + this._freed = true; + this._sendCommand("QUIT"); + this._currentAction = this.close; + } + + /** + * Closes the connection to the server + * + * @param {boolean} [immediately] - Close the socket without waiting for + * unsent data. + */ + close(immediately) { + if (this.socket && this.socket.readyState === "open") { + if (immediately) { + this.logger.debug( + `Closing connection to ${this._server.hostname} immediately!` + ); + this.socket.closeImmediately(); + } else { + this.logger.debug(`Closing connection to ${this._server.hostname}...`); + this.socket.close(); + } + } else { + this.logger.debug(`Connection to ${this._server.hostname} closed`); + this._free(); + } + } + + // Mail related methods + + /** + * Initiates a new message by submitting envelope data, starting with + * `MAIL FROM:` command. Use after `onidle` event + * + * @param {object} envelope - The envelope object. + * @param {string} envelope.from - The from address. + * @param {string[]} envelope.to - The to addresses. + * @param {number} envelope.size - The file size. + * @param {boolean} envelope.requestDSN - Whether to request Delivery Status Notifications. + * @param {boolean} envelope.messageId - The message id. + */ + useEnvelope(envelope) { + this._envelope = envelope || {}; + this._envelope.from = [].concat( + this._envelope.from || "anonymous@" + this._getHelloArgument() + )[0]; + + if (!this._capabilities.includes("SMTPUTF8")) { + // If server doesn't support SMTPUTF8, check if addresses contain invalid + // characters. + + let recipients = this._envelope.to; + this._envelope.to = []; + + for (let recipient of recipients) { + let lastAt = null; + let firstInvalid = null; + for (let i = 0; i < recipient.length; i++) { + let ch = recipient[i]; + if (ch == "@") { + lastAt = i; + } else if ((ch < " " || ch > "~") && ch != "\t") { + firstInvalid = i; + break; + } + } + if (!recipient || firstInvalid != null) { + if (!lastAt) { + // Invalid char found in the localpart, throw error until we implement RFC 6532. + this._onNsError(MsgUtils.NS_ERROR_ILLEGAL_LOCALPART, recipient); + return; + } + // Invalid char found in the domainpart, convert it to ACE. + let idnService = Cc["@mozilla.org/network/idn-service;1"].getService( + Ci.nsIIDNService + ); + let domain = idnService.convertUTF8toACE(recipient.slice(lastAt + 1)); + recipient = `${recipient.slice(0, lastAt)}@${domain}`; + } + this._envelope.to.push(recipient); + } + } + + // clone the recipients array for latter manipulation + this._envelope.rcptQueue = [...new Set(this._envelope.to)]; + this._envelope.rcptFailed = []; + this._envelope.responseQueue = []; + + if (!this._envelope.rcptQueue.length) { + this._onNsError(MsgUtils.NS_MSG_NO_RECIPIENTS); + return; + } + + this._currentAction = this._actionMAIL; + let cmd = `MAIL FROM:<${this._envelope.from}>`; + if ( + this._capabilities.includes("8BITMIME") && + !Services.prefs.getBoolPref("mail.strictly_mime", false) + ) { + cmd += " BODY=8BITMIME"; + } + if (this._capabilities.includes("SMTPUTF8")) { + // Should not send SMTPUTF8 if all ascii, see RFC6531. + // eslint-disable-next-line no-control-regex + let ascii = /^[\x00-\x7F]+$/; + if ([envelope.from, ...envelope.to].some(x => !ascii.test(x))) { + cmd += " SMTPUTF8"; + } + } + if (this._capabilities.includes("SIZE")) { + cmd += ` SIZE=${this._envelope.size}`; + } + if (this._capabilities.includes("DSN") && this._envelope.requestDSN) { + let ret = Services.prefs.getBoolPref("mail.dsn.ret_full_on") + ? "FULL" + : "HDRS"; + cmd += ` RET=${ret} ENVID=${envelope.messageId}`; + } + this._sendCommand(cmd); + } + + /** + * Send ASCII data to the server. Works only in data mode (after `onready` event), ignored + * otherwise + * + * @param {string} chunk ASCII string (quoted-printable, base64 etc.) to be sent to the server + * @returns {boolean} If true, it is safe to send more data, if false, you *should* wait for the ondrain event before sending more + */ + send(chunk) { + // works only in data mode + if (!this._dataMode) { + // this line should never be reached but if it does, + // act like everything's normal. + return true; + } + + // TODO: if the chunk is an arraybuffer, use a separate function to send the data + return this._sendString(chunk); + } + + /** + * Indicates that a data stream for the socket is ended. Works only in data + * mode (after `onready` event), ignored otherwise. Use it when you are done + * with sending the mail. This method does not close the socket. Once the mail + * has been queued by the server, `ondone` and `onidle` are emitted. + * + * @param {Buffer} [chunk] Chunk of data to be sent to the server + */ + end(chunk) { + // works only in data mode + if (!this._dataMode) { + // this line should never be reached but if it does, + // act like everything's normal. + return true; + } + + if (chunk && chunk.length) { + this.send(chunk); + } + + // redirect output from the server to _actionStream + this._currentAction = this._actionStream; + + // indicate that the stream has ended by sending a single dot on its own line + // if the client already closed the data with \r\n no need to do it again + if (this._lastDataBytes === "\r\n") { + this.waitDrain = this._send(new Uint8Array([0x2e, 0x0d, 0x0a]).buffer); // .\r\n + } else if (this._lastDataBytes.substr(-1) === "\r") { + this.waitDrain = this._send( + new Uint8Array([0x0a, 0x2e, 0x0d, 0x0a]).buffer + ); // \n.\r\n + } else { + this.waitDrain = this._send( + new Uint8Array([0x0d, 0x0a, 0x2e, 0x0d, 0x0a]).buffer + ); // \r\n.\r\n + } + + // End data mode. + this._dataMode = false; + + return this.waitDrain; + } + + // PRIVATE METHODS + + /** + * Queue some data from the server for parsing. + * + * @param {string} chunk Chunk of data received from the server + */ + _parse(chunk) { + // Lines should always end with <CR><LF> but you never know, might be only <LF> as well + var lines = (this._parseRemainder + (chunk || "")).split(/\r?\n/); + this._parseRemainder = lines.pop(); // not sure if the line has completely arrived yet + + for (let i = 0, len = lines.length; i < len; i++) { + if (!lines[i].trim()) { + // nothing to check, empty line + continue; + } + + // possible input strings for the regex: + // 250-MULTILINE REPLY + // 250 LAST LINE OF REPLY + // 250 1.2.3 MESSAGE + + const match = lines[i].match( + /^(\d{3})([- ])(?:(\d+\.\d+\.\d+)(?: ))?(.*)/ + ); + + if (match) { + this._parseBlock.data.push(match[4]); + + if (match[2] === "-") { + // this is a multiline reply + this._parseBlock.statusCode = + this._parseBlock.statusCode || Number(match[1]); + } else { + const statusCode = Number(match[1]) || 0; + const response = { + statusCode, + data: this._parseBlock.data.join("\n"), + // Success means can move to the next step. Though 3xx is not + // failure, we don't consider it success here. + success: statusCode >= 200 && statusCode < 300, + }; + + this._onCommand(response); + this._parseBlock = { + data: [], + statusCode: null, + }; + } + } else { + this._onCommand({ + success: false, + statusCode: this._parseBlock.statusCode || null, + data: [lines[i]].join("\n"), + }); + this._parseBlock = { + data: [], + statusCode: null, + }; + } + } + } + + // EVENT HANDLERS FOR THE SOCKET + + /** + * Connection listener that is run when the connection to the server is opened. + * Sets up different event handlers for the opened socket + */ + _onOpen = () => { + this.logger.debug("Connected"); + + this.socket.ondata = this._onData; + this.socket.onclose = this._onClose; + this.socket.ondrain = this._onDrain; + + this._currentAction = this._actionGreeting; + this.socket.transport.setTimeout( + Ci.nsISocketTransport.TIMEOUT_READ_WRITE, + Services.prefs.getIntPref("mailnews.tcptimeout") + ); + }; + + /** + * Data listener for chunks of data emitted by the server + * + * @param {Event} evt - Event object. See `evt.data` for the chunk received + */ + _onData = async evt => { + let stringPayload = new TextDecoder("UTF-8").decode( + new Uint8Array(evt.data) + ); + // "S: " to denote that this is data from the Server. + this.logger.debug(`S: ${stringPayload}`); + + // Prevent blocking the main thread, otherwise onclose/onerror may not be + // called in time. test_smtpPasswordFailure3 is such a case, the server + // rejects AUTH PLAIN then closes the connection, the client then sends AUTH + // LOGIN. This line guarantees onclose is called before sending AUTH LOGIN. + await new Promise(resolve => setTimeout(resolve)); + this._parse(stringPayload); + }; + + /** + * More data can be buffered in the socket, `waitDrain` is reset to false + */ + _onDrain = () => { + this.waitDrain = false; + this.ondrain(); + }; + + /** + * Error handler. Emits an nsresult value. + * + * @param {Error|TCPSocketErrorEvent} event - An Error or TCPSocketErrorEvent object. + */ + _onError = async event => { + this.logger.error(`${event.name}: a ${event.message} error occurred`); + if (this._freed) { + // Ignore socket errors if already freed. + return; + } + + this._free(); + this.quit(); + + let nsError = Cr.NS_ERROR_FAILURE; + let secInfo = null; + if (TCPSocketErrorEvent.isInstance(event)) { + nsError = event.errorCode; + 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._server.closeCachedConnections(); + } + } + + // Use nsresult to integrate with other parts of sending process, e.g. + // MessageSend.jsm will show an error message depending on the nsresult. + this.onerror(nsError, "", secInfo); + }; + + /** + * Error handler. Emits an nsresult value. + * + * @param {nsresult} nsError - A nsresult. + * @param {string} errorParam - Param to form the error message. + * @param {string} [extra] - Some messages take two arguments to format. + * @param {number} [statusCode] - Only needed when checking need to retry. + */ + _onNsError(nsError, errorParam, extra, statusCode) { + // First check if handling an error response that might need a retry. + if ([this._actionMAIL, this._actionRCPT].includes(this._currentAction)) { + if (statusCode >= 400 && statusCode < 500) { + // Possibly too many recipients, too many messages, to much data + // or too much time has elapsed on this connection. + if (!this.isRetry) { + // Now seeing error 4xx meaning that the current message can't be + // accepted. We close the connection and try again to send on a new + // connection using this same client instance. If the retry also + // fails on the new connection, we give up and report the error. + this.logger.debug("Retry send on new connection."); + this.quit(); + this.isRetry = true; // flag that we will retry on new connection + this.close(true); + this.connect(); + return; // return without reporting the error yet + } + } + } + + let errorName = MsgUtils.getErrorStringName(nsError); + let errorMessage = ""; + if ( + [ + MsgUtils.NS_ERROR_SMTP_SERVER_ERROR, + MsgUtils.NS_ERROR_SMTP_TEMP_SIZE_EXCEEDED, + MsgUtils.NS_ERROR_SMTP_PERM_SIZE_EXCEEDED_2, + MsgUtils.NS_ERROR_SENDING_FROM_COMMAND, + MsgUtils.NS_ERROR_SENDING_RCPT_COMMAND, + MsgUtils.NS_ERROR_SENDING_DATA_COMMAND, + MsgUtils.NS_ERROR_SENDING_MESSAGE, + MsgUtils.NS_ERROR_ILLEGAL_LOCALPART, + ].includes(nsError) + ) { + let bundle = Services.strings.createBundle( + "chrome://messenger/locale/messengercompose/composeMsgs.properties" + ); + if (nsError == MsgUtils.NS_ERROR_ILLEGAL_LOCALPART) { + errorMessage = bundle + .GetStringFromName(errorName) + .replace("%s", errorParam); + } else { + errorMessage = bundle.formatStringFromName(errorName, [ + errorParam, + extra, + ]); + } + } + this.onerror(nsError, errorMessage); + this.close(); + } + + /** + * Indicates that the socket has been closed + */ + _onClose = () => { + this.logger.debug("Socket closed."); + this._free(); + this.rcptCount = 0; + if (this._authenticating) { + // In some cases, socket is closed for invalid username/password. + this._onAuthFailed({ data: "Socket closed." }); + } + }; + + /** + * This is not a socket data handler but the handler for data emitted by the parser, + * so this data is safe to use as it is always complete (server might send partial chunks) + * + * @param {object} command - Parsed data. + */ + _onCommand(command) { + if (command.statusCode < 200 || command.statusCode >= 400) { + // @see https://datatracker.ietf.org/doc/html/rfc5321#section-3.8 + // 421: SMTP service shutting down and closing transmission channel. + // When that happens during idle, just close the connection. + if ( + command.statusCode == 421 && + this._currentAction == this._actionIdle + ) { + this.close(true); + return; + } + + this.logger.error( + `Command failed: ${command.statusCode} ${command.data}; currentAction=${this._currentAction?.name}` + ); + } + if (typeof this._currentAction === "function") { + this._currentAction(command); + } + } + + /** + * This client has finished the current process and ready to be reused. + */ + _free() { + if (!this._freed) { + this._freed = true; + this.onFree(); + } + } + + /** + * Sends a string to the socket. + * + * @param {string} chunk ASCII string (quoted-printable, base64 etc.) to be sent to the server + * @returns {boolean} If true, it is safe to send more data, if false, you *should* wait for the ondrain event before sending more + */ + _sendString(chunk) { + // escape dots + if (!this.options.disableEscaping) { + chunk = chunk.replace(/\n\./g, "\n.."); + if ( + (this._lastDataBytes.substr(-1) === "\n" || !this._lastDataBytes) && + chunk.charAt(0) === "." + ) { + chunk = "." + chunk; + } + } + + // Keeping eye on the last bytes sent, to see if there is a <CR><LF> sequence + // at the end which is needed to end the data stream + if (chunk.length > 2) { + this._lastDataBytes = chunk.substr(-2); + } else if (chunk.length === 1) { + this._lastDataBytes = this._lastDataBytes.substr(-1) + chunk; + } + + this.logger.debug("Sending " + chunk.length + " bytes of payload"); + + // pass the chunk to the socket + this.waitDrain = this._send( + MailStringUtils.byteStringToUint8Array(chunk).buffer + ); + return this.waitDrain; + } + + /** + * Send a string command to the server, also append CRLF if needed. + * + * @param {string} str - String to be sent to the server. + * @param {boolean} [suppressLogging=false] - If true and not in dev mode, + * do not log the str. For non-release builds output won't be suppressed, + * so that debugging auth problems is easier. + */ + _sendCommand(str, suppressLogging = false) { + if (this.socket.readyState !== "open") { + if (str != "QUIT") { + this.logger.warn( + `Failed to send "${str}" because socket state is ${this.socket.readyState}` + ); + } + return; + } + // "C: " is used to denote that this is data from the Client. + if (suppressLogging && AppConstants.MOZ_UPDATE_CHANNEL != "default") { + this.logger.debug( + "C: Logging suppressed (it probably contained auth information)" + ); + } else { + this.logger.debug(`C: ${str}`); + } + this.waitDrain = this._send( + new TextEncoder().encode(str + (str.substr(-2) !== "\r\n" ? "\r\n" : "")) + .buffer + ); + } + + _send(buffer) { + return this.socket.send(buffer); + } + + /** + * Intitiate authentication sequence if needed + * + * @param {boolean} forceNewPassword - Discard cached password. + */ + async _authenticateUser(forceNewPassword) { + if ( + this._preferredAuthMethods.length == 0 || + this._supportedAuthMethods.length == 0 + ) { + // no need to authenticate, at least no data given + this._currentAction = this._actionIdle; + this.onidle(); // ready to take orders + return; + } + + if (!this._nextAuthMethod) { + this._onAuthFailed({ data: "No available auth method." }); + 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}`); + + switch (this._currentAuthMethod) { + case "LOGIN": + // LOGIN is a 3 step authentication process + // C: AUTH LOGIN + // C: BASE64(USER) + // C: BASE64(PASS) + this.logger.debug("Authentication via AUTH LOGIN"); + this._currentAction = this._actionAUTH_LOGIN_USER; + this._sendCommand("AUTH LOGIN"); + return; + case "PLAIN": + // AUTH PLAIN is a 1 step authentication process + // C: AUTH PLAIN BASE64(\0 USER \0 PASS) + this.logger.debug("Authentication via AUTH PLAIN"); + this._currentAction = this._actionAUTHComplete; + this._sendCommand( + "AUTH PLAIN " + this._authenticator.getPlainToken(), + true + ); + return; + case "CRAM-MD5": + this.logger.debug("Authentication via AUTH CRAM-MD5"); + this._currentAction = this._actionAUTH_CRAM; + this._sendCommand("AUTH CRAM-MD5"); + return; + case "XOAUTH2": + // See https://developers.google.com/gmail/xoauth2_protocol#smtp_protocol_exchange + this.logger.debug("Authentication via AUTH XOAUTH2"); + this._currentAction = this._actionAUTH_XOAUTH2; + let oauthToken = await this._authenticator.getOAuthToken(); + this._sendCommand("AUTH XOAUTH2 " + oauthToken, true); + return; + case "GSSAPI": { + this.logger.debug("Authentication via AUTH GSSAPI"); + this._currentAction = this._actionAUTH_GSSAPI; + this._authenticator.initGssapiAuth("smtp"); + let token; + try { + token = this._authenticator.getNextGssapiToken(""); + } catch (e) { + this.logger.error(e); + this._actionAUTHComplete({ success: false, data: "AUTH GSSAPI" }); + return; + } + this._sendCommand(`AUTH GSSAPI ${token}`, true); + return; + } + case "NTLM": { + this.logger.debug("Authentication via AUTH NTLM"); + this._currentAction = this._actionAUTH_NTLM; + this._authenticator.initNtlmAuth("smtp"); + let token; + try { + token = this._authenticator.getNextNtlmToken(""); + } catch (e) { + this.logger.error(e); + this._actionAUTHComplete({ success: false, data: "AUTH NTLM" }); + return; + } + this._sendCommand(`AUTH NTLM ${token}`, true); + return; + } + } + + this._onAuthFailed({ + data: `Unknown authentication method ${this._currentAuthMethod}`, + }); + } + + _onAuthFailed(command) { + this.logger.error(`Authentication failed: ${command.data}`); + if (!this._freed) { + if (this._nextAuthMethod) { + // Try the next auth method. + this._authenticateUser(); + return; + } else if (!this._currentAuthMethod) { + // No auth method was even tried. + let err; + if ( + this._server.authMethod == Ci.nsMsgAuthMethod.passwordEncrypted && + (this._supportedAuthMethods.includes("PLAIN") || + this._supportedAuthMethods.includes("LOGIN")) + ) { + // Pref has encrypted password, server claims to support plaintext + // password. + err = [ + Ci.nsMsgSocketType.alwaysSTARTTLS, + Ci.nsMsgSocketType.SSL, + ].includes(this._server.socketType) + ? MsgUtils.NS_ERROR_SMTP_AUTH_CHANGE_ENCRYPT_TO_PLAIN_SSL + : MsgUtils.NS_ERROR_SMTP_AUTH_CHANGE_ENCRYPT_TO_PLAIN_NO_SSL; + } else if ( + this._server.authMethod == Ci.nsMsgAuthMethod.passwordCleartext && + this._supportedAuthMethods.includes("CRAM-MD5") + ) { + // Pref has plaintext password, server claims to support encrypted + // password. + err = MsgUtils.NS_ERROR_SMTP_AUTH_CHANGE_PLAIN_TO_ENCRYPT; + } else { + err = MsgUtils.NS_ERROR_SMTP_AUTH_MECH_NOT_SUPPORTED; + } + this._onNsError(err); + return; + } + } + + // Ask user what to do. + let action = this._authenticator.promptAuthFailed(); + if (action == 1) { + // Cancel button pressed. + this.logger.error(`Authentication failed: ${command.data}`); + this._onNsError(MsgUtils.NS_ERROR_SMTP_AUTH_FAILURE); + return; + } else if (action == 2) { + // 'New password' button pressed. Forget cached password, new password + // will be asked. + this._authenticator.forgetPassword(); + } + + if (this._freed) { + // If connection is lost, reconnect. + this.connect(); + return; + } + + // Reset _nextAuthMethod to start again. + this._nextAuthMethod = this._possibleAuthMethods[0]; + if (action == 2 || action == 0) { + // action = 0 means retry button pressed. + this._authenticateUser(); + } + } + + _getHelloArgument() { + let helloArgument = this._server.helloArgument; + if (helloArgument) { + return helloArgument; + } + + try { + // The address format follows rfc5321#section-4.1.3. + let netAddr = this.socket?.transport.getScriptableSelfAddr(); + let address = netAddr.address; + if (netAddr.family === Ci.nsINetAddr.FAMILY_INET6) { + return `[IPV6:${address}]`; + } + return `[${address}]`; + } catch (e) {} + + return "[127.0.0.1]"; + } + + // ACTIONS FOR RESPONSES FROM THE SMTP SERVER + + /** + * Initial response from the server, must have a status 220 + * + * @param {object} command Parsed command from the server {statusCode, data} + */ + _actionGreeting(command) { + if (command.statusCode !== 220) { + this._onNsError(MsgUtils.NS_ERROR_SMTP_SERVER_ERROR, command.data); + return; + } + + if (this.options.lmtp) { + this._currentAction = this._actionLHLO; + this._sendCommand("LHLO " + this._getHelloArgument()); + } else { + this._currentAction = this._actionEHLO; + this._sendCommand("EHLO " + this._getHelloArgument()); + } + } + + /** + * Response to LHLO + * + * @param {object} command Parsed command from the server {statusCode, data} + */ + _actionLHLO(command) { + if (!command.success) { + this._onNsError(MsgUtils.NS_ERROR_SMTP_SERVER_ERROR, command.data); + return; + } + + // Process as EHLO response + this._actionEHLO(command); + } + + /** + * Response to EHLO. If the response is an error, try HELO instead + * + * @param {object} command Parsed command from the server {statusCode, data} + */ + _actionEHLO(command) { + if ([500, 502].includes(command.statusCode)) { + // EHLO is not implemented by the server. + if (this.options.alwaysSTARTTLS) { + // If alwaysSTARTTLS is set by the user, EHLO is required to advertise it. + this._onNsError(MsgUtils.NS_ERROR_STARTTLS_FAILED_EHLO_STARTTLS); + return; + } + + // Try HELO instead + this.logger.warn( + "EHLO not successful, trying HELO " + this._getHelloArgument() + ); + this._currentAction = this._actionHELO; + this._sendCommand("HELO " + this._getHelloArgument()); + return; + } else if (!command.success) { + // 501 Syntax error or some other error. + this._onNsError(MsgUtils.NS_ERROR_SMTP_SERVER_ERROR, command.data); + return; + } + + this._supportedAuthMethods = []; + + let lines = command.data.toUpperCase().split("\n"); + // Skip the first greeting line. + for (let line of lines.slice(1)) { + if (line.startsWith("AUTH ")) { + this._supportedAuthMethods = line.slice(5).split(" "); + } else { + this._capabilities.push(line.split(" ")[0]); + } + } + + if (!this._secureTransport && this.options.alwaysSTARTTLS) { + // STARTTLS is required by the user. Detect if the server supports it. + if (this._capabilities.includes("STARTTLS")) { + this._currentAction = this._actionSTARTTLS; + this._sendCommand("STARTTLS"); + return; + } + // STARTTLS is required but not advertised. + this._onNsError(MsgUtils.NS_ERROR_STARTTLS_FAILED_EHLO_STARTTLS); + 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) + ); + this.logger.debug(`Possible auth methods: ${this._possibleAuthMethods}`); + this._nextAuthMethod = this._possibleAuthMethods[0]; + + if ( + this._capabilities.includes("CLIENTID") && + (this._secureTransport || + // For test purpose. + ["localhost", "127.0.0.1", "::1"].includes(this._server.hostname)) && + this._server.clientidEnabled && + this._server.clientid + ) { + // Client identity extension, still a draft. + this._currentAction = this._actionCLIENTID; + this._sendCommand("CLIENTID UUID " + this._server.clientid, true); + } else { + this._authenticateUser(); + } + } + + /** + * Handles server response for STARTTLS command. If there's an error + * try HELO instead, otherwise initiate TLS upgrade. If the upgrade + * succeeds restart the EHLO + * + * @param {string} command - Message from the server. + */ + _actionSTARTTLS(command) { + if (!command.success) { + this._onNsError(MsgUtils.NS_ERROR_SMTP_SERVER_ERROR, command.data); + return; + } + + this.socket.upgradeToSecure(); + this._secureTransport = true; + + // restart protocol flow + this._currentAction = this._actionEHLO; + this._sendCommand("EHLO " + this._getHelloArgument()); + } + + /** + * Response to HELO + * + * @param {object} command Parsed command from the server {statusCode, data} + */ + _actionHELO(command) { + if (!command.success) { + this._onNsError(MsgUtils.NS_ERROR_SMTP_SERVER_ERROR, command.data); + return; + } + this._authenticateUser(); + } + + /** + * Handles server response for CLIENTID command. If successful then will + * initiate the authenticateUser process. + * + * @param {object} command Parsed command from the server {statusCode, data} + */ + _actionCLIENTID(command) { + if (!command.success) { + this._onNsError(MsgUtils.NS_ERROR_SMTP_SERVER_ERROR, command.data); + return; + } + this._authenticateUser(); + } + + /** + * Returns the saved/cached server password, or show a password dialog. If the + * user cancels the dialog, abort sending. + * + * @returns {string} The server password. + */ + _getPassword() { + try { + return this._authenticator.getPassword(); + } catch (e) { + if (e.result == Cr.NS_ERROR_ABORT) { + this.quit(); + this.onerror(e.result); + } else { + throw e; + } + } + return null; + } + + /** + * Response to AUTH LOGIN, if successful expects base64 encoded username + * + * @param {object} command Parsed command from the server {statusCode, data} + */ + _actionAUTH_LOGIN_USER(command) { + if (command.statusCode !== 334 || command.data !== "VXNlcm5hbWU6") { + this._onNsError(MsgUtils.NS_ERROR_SMTP_AUTH_FAILURE, command.data); + return; + } + this.logger.debug("AUTH LOGIN USER"); + this._currentAction = this._actionAUTH_LOGIN_PASS; + this._sendCommand(btoa(this._authenticator.username), true); + } + + /** + * Process the response to AUTH LOGIN with a username. If successful, expects + * a base64-encoded password. + * + * @param {{statusCode: number, data: string}} command - Parsed command from + * the server. + */ + _actionAUTH_LOGIN_PASS(command) { + if ( + command.statusCode !== 334 || + (command.data !== btoa("Password:") && command.data !== btoa("password:")) + ) { + this._onNsError(MsgUtils.NS_ERROR_SMTP_AUTH_FAILURE, command.data); + return; + } + this.logger.debug("AUTH LOGIN PASS"); + this._currentAction = this._actionAUTHComplete; + let password = this._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. + password = MailStringUtils.stringToByteString(password); + } + this._sendCommand(btoa(password), true); + } + + /** + * Response to AUTH CRAM, if successful expects base64 encoded challenge. + * + * @param {object} command Parsed command from the server {statusCode, data} + */ + async _actionAUTH_CRAM(command) { + if (command.statusCode !== 334) { + this._onNsError(MsgUtils.NS_ERROR_SMTP_AUTH_FAILURE, command.data); + return; + } + this._currentAction = this._actionAUTHComplete; + this._sendCommand( + this._authenticator.getCramMd5Token(this._getPassword(), command.data), + true + ); + } + + /** + * Response to AUTH XOAUTH2 token, if error occurs send empty response + * + * @param {object} command Parsed command from the server {statusCode, data} + */ + _actionAUTH_XOAUTH2(command) { + if (!command.success) { + this.logger.warn("Error during AUTH XOAUTH2, sending empty response"); + this._sendCommand(""); + this._currentAction = this._actionAUTHComplete; + } else { + this._actionAUTHComplete(command); + } + } + + /** + * Response to AUTH GSSAPI, if successful expects a base64 encoded challenge. + * + * @param {object} command Parsed command from the server {statusCode, data} + */ + _actionAUTH_GSSAPI(command) { + // GSSAPI auth can be multiple steps. We exchange tokens with the server + // until success or failure. + if (command.success) { + this._actionAUTHComplete(command); + return; + } + if (command.statusCode !== 334) { + this._onNsError(MsgUtils.NS_ERROR_SMTP_AUTH_GSSAPI, command.data); + return; + } + let token = this._authenticator.getNextGssapiToken(command.data); + this._currentAction = this._actionAUTH_GSSAPI; + this._sendCommand(token, true); + } + + /** + * Response to AUTH NTLM, if successful expects a base64 encoded challenge. + * + * @param {object} command Parsed command from the server {statusCode, data} + */ + _actionAUTH_NTLM(command) { + // NTLM auth can be multiple steps. We exchange tokens with the server + // until success or failure. + if (command.success) { + this._actionAUTHComplete(command); + return; + } + if (command.statusCode !== 334) { + this._onNsError(MsgUtils.NS_ERROR_SMTP_AUTH_FAILURE, command.data); + return; + } + let token = this._authenticator.getNextNtlmToken(command.data); + this._currentAction = this._actionAUTH_NTLM; + this._sendCommand(token, true); + } + + /** + * Checks if authentication succeeded or not. If successfully authenticated + * emit `idle` to indicate that an e-mail can be sent using this connection + * + * @param {object} command Parsed command from the server {statusCode, data} + */ + _actionAUTHComplete(command) { + this._authenticating = false; + if (!command.success) { + this._onAuthFailed(command); + return; + } + + this.logger.debug("Authentication successful."); + + this._currentAction = this._actionIdle; + this.onidle(); // ready to take orders + } + + /** + * Used when the connection is idle, not expecting anything from the server. + * + * @param {object} command Parsed command from the server {statusCode, data} + */ + _actionIdle(command) { + this._onNsError(MsgUtils.NS_ERROR_SMTP_SERVER_ERROR, command.data); + } + + /** + * Response to MAIL FROM command. Proceed to defining RCPT TO list if successful + * + * @param {object} command Parsed command from the server {statusCode, data} + */ + _actionMAIL(command) { + if (!command.success) { + let errorCode = MsgUtils.NS_ERROR_SENDING_FROM_COMMAND; // default code + if (command.statusCode == 552) { + // Too much mail data indicated by "size" parameter of MAIL FROM. + // @see https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.3.1.9 + errorCode = MsgUtils.NS_ERROR_SMTP_PERM_SIZE_EXCEEDED_2; + } + if (command.statusCode == 452 || command.statusCode == 451) { + // @see https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.3.1.10 + errorCode = MsgUtils.NS_ERROR_SMTP_TEMP_SIZE_EXCEEDED; + } + this._onNsError(errorCode, command.data, null, command.statusCode); + return; + } + this.logger.debug( + "MAIL FROM successful, proceeding with " + + this._envelope.rcptQueue.length + + " recipients" + ); + this.logger.debug("Adding recipient..."); + this._envelope.curRecipient = this._envelope.rcptQueue.shift(); + this._currentAction = this._actionRCPT; + this._sendCommand( + `RCPT TO:<${this._envelope.curRecipient}>${this._getRCPTParameters()}` + ); + } + + /** + * Prepare the RCPT params, currently only DSN params. If the server supports + * DSN and sender requested DSN, append DSN params to each RCPT TO command. + */ + _getRCPTParameters() { + if (this._capabilities.includes("DSN") && this._envelope.requestDSN) { + let notify = []; + if (Services.prefs.getBoolPref("mail.dsn.request_never_on")) { + notify.push("NEVER"); + } else { + if (Services.prefs.getBoolPref("mail.dsn.request_on_success_on")) { + notify.push("SUCCESS"); + } + if (Services.prefs.getBoolPref("mail.dsn.request_on_failure_on")) { + notify.push("FAILURE"); + } + if (Services.prefs.getBoolPref("mail.dsn.request_on_delay_on")) { + notify.push("DELAY"); + } + } + if (notify.length > 0) { + return ` NOTIFY=${notify.join(",")}`; + } + } + return ""; + } + + /** + * Response to a RCPT TO command. If the command is unsuccessful, emit an + * error to abort the sending. + * + * @param {object} command Parsed command from the server {statusCode, data} + */ + _actionRCPT(command) { + if (!command.success) { + this._onNsError( + MsgUtils.NS_ERROR_SENDING_RCPT_COMMAND, + command.data, + this._envelope.curRecipient, + command.statusCode + ); + return; + } + this.rcptCount++; + this._envelope.responseQueue.push(this._envelope.curRecipient); + + if (this._envelope.rcptQueue.length) { + // Send the next recipient. + this._envelope.curRecipient = this._envelope.rcptQueue.shift(); + this._currentAction = this._actionRCPT; + this._sendCommand( + `RCPT TO:<${this._envelope.curRecipient}>${this._getRCPTParameters()}` + ); + } else { + this.logger.debug( + `Total RCPTs during this connection: ${this.rcptCount}` + ); + this.logger.debug("RCPT TO done. Proceeding with payload."); + this._currentAction = this._actionDATA; + this._sendCommand("DATA"); + } + } + + /** + * Response to the DATA command. Server is now waiting for a message, so emit `onready` + * + * @param {object} command Parsed command from the server {statusCode, data} + */ + _actionDATA(command) { + // response should be 354 but according to this issue https://github.com/eleith/emailjs/issues/24 + // some servers might use 250 instead + if (![250, 354].includes(command.statusCode)) { + this._onNsError(MsgUtils.NS_ERROR_SENDING_DATA_COMMAND, command.data); + return; + } + + this._dataMode = true; + this._currentAction = this._actionIdle; + this.onready(this._envelope.rcptFailed); + } + + /** + * Response from the server, once the message stream has ended with <CR><LF>.<CR><LF> + * Emits `ondone`. + * + * @param {object} command Parsed command from the server {statusCode, data} + */ + _actionStream(command) { + var rcpt; + + if (this.options.lmtp) { + // LMTP returns a response code for *every* successfully set recipient + // For every recipient the message might succeed or fail individually + + rcpt = this._envelope.responseQueue.shift(); + if (!command.success) { + this.logger.error("Local delivery to " + rcpt + " failed."); + this._envelope.rcptFailed.push(rcpt); + } else { + this.logger.error("Local delivery to " + rcpt + " succeeded."); + } + + if (this._envelope.responseQueue.length) { + this._currentAction = this._actionStream; + return; + } + + this._currentAction = this._actionIdle; + this.ondone(0); + } else { + // For SMTP the message either fails or succeeds, there is no information + // about individual recipients + + if (!command.success) { + this.logger.error("Message sending failed."); + } else { + this.logger.debug("Message sent successfully."); + this.isRetry = false; + } + + this._currentAction = this._actionIdle; + if (command.success) { + this.ondone(0); + } else { + this._onNsError(MsgUtils.NS_ERROR_SENDING_MESSAGE, command.data); + } + } + + this._freed = true; + this.onFree(); + } +} |