Revision control
Copy as Markdown
Other Tools
/* 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
*
*
* 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.
*/
export class SmtpClient {
/**
* Set true only when doing a retry. (Also used in SmtpServer)
*/
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 {SmtpServer} server - The associated SmtpServer instance.
*/
constructor(server) {
this.options = {
alwaysSTARTTLS: 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;
/**
* The number of RCPT TO commands sent on a connection by this client.
* This can count-up over multiple messages.
* Per RFC, the minimum total number of recipients that MUST be buffered
* is 100 recipients.
* When 100 or more recipients have been counted on a connection, a new
* connection will be established to handle any additional messages.
* Note: This does NOT prevent a single message with 100 or more recipients
* from being attempted to be sent nor does it split the message into multiple
* messages with 100 or fewer recipients.
*/
this._rcptCount = 0;
this._numMessages = 0; // Count of number of messages sent on a connection.
this._isDelayedQuitSent = false; // Set true when delayed QUIT is sent
// Event placeholders
this.onerror = () => {}; // 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 = () => {}; // Waiting for mail body, lists addresses that were not accepted as recipients
this.ondone = () => {}; // The mail has been sent. Wait for `onidle` next. Indicates if the message was queued by the server.
this.onFree = () => {}; // Called when done using this SmtpClient instance for now.
this.logger.debug("New client instance");
}
/**
* Initiate a connection to the server or reuse the existing connection to
* send the message. This occurs each time a message is sent.
*/
connect() {
// First, clear the QUIT timer if it's running.
if (this._quitTimer) {
clearTimeout(this._quitTimer);
this._quitTimer = null;
}
if (this.socket?.readyState == "open") {
this.logger.debug("Reusing a connection");
this.onidle();
} else {
const hostname = this._server.hostname.toLowerCase();
const port = this._server.port || (this.options.requireTLS ? 465 : 587);
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;
// Reset these counters when a new connection is opened. When the number
// of messages sent or the number of recipients for the messages reaches
// their respective threshold, a new connection will be established.
this._numMessages = 0;
this._rcptCount = 0;
}
this._freed = false;
}
/**
* Sends QUIT.
* Ignore the response to QUIT (i.e., "221 <text>") since connection is done.
* Also, don't do close() which confuses the server since, per RFC, the
* connection close is be initiated by the server after receiving QUIT.
*/
quit() {
this._authenticating = false;
this._currentAction = null; // Do no action after response to QUIT.
this._sendCommand("QUIT");
}
/**
* 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.
const recipients = this._envelope.to;
this._envelope.to = [];
for (let recipient of recipients) {
if (!recipient) {
// This happens when SmtpServer.sendMailMessage() is
// called with recipients without @, for example in
// test_sendMailAddressIDN.js.
continue;
}
let lastAt = null;
let firstInvalid = null;
for (let i = 0; i < recipient.length; i++) {
const ch = recipient[i];
if (ch == "@") {
lastAt = i;
} else if ((ch < " " || ch > "~") && ch != "\t") {
firstInvalid = i;
break;
}
}
if (firstInvalid != null) {
if (!lastAt) {
// Invalid char found in the localpart, throw error until we implement RFC 6532.
this._onNsError("errorIllegalLocalPart2", recipient);
return;
}
// Invalid char found in the domainpart, convert it to ACE.
const idnService = Cc[
"@mozilla.org/network/idn-service;1"
].getService(Ci.nsIIDNService);
const 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("noRecipients");
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
const 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) {
const 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 => {
const 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(true); // true => call quit() before freeing.
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) {
const 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.sys.mjs will show an error message depending on the nsresult.
this.onerror(nsError, "", secInfo);
};
/**
* Error handler. Emits an nsresult value.
*
* @param {string} error - An error code for l10n.
* @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(error, 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
}
}
}
const bundle = Services.strings.createBundle(
);
this.onerror(
Cr.NS_ERROR_FAILURE,
bundle.formatStringFromName(error, [errorParam, extra])
);
this.close();
}
/**
* Callback from network signaling that the socket is now closed
*/
_onClose = () => {
this.logger.debug("Socket closed.");
this._free();
this._isDelayedQuitSent = false;
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) {
// 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);
}
}
/**
* When we have finished sending a message or if message can't be sent due to
* an error, this is called to free this client instance to make it available
* for reuse (unless already freed).
*
* @param {boolean} [sendQuit = false] - When true, send smtp QUIT before
* freeing. This occurs only when a new connection is to be used for the
* next message to be sent (connection not reused).
*/
_free(sendQuit = false) {
if (!this._freed) {
this._freed = true;
if (sendQuit) {
this.quit();
}
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) {
const isSocketOpen = this.socket?.readyState == "open";
if (!isSocketOpen || this._isDelayedQuitSent) {
// If delayed QUIT is known to have been sent previously (i.e., we are
// just starting to reuse an existing connection), this connection is going
// down or it might already be down. So don't try to send now but restart
// this send again on a new connection.
if (this._isDelayedQuitSent) {
this.logger.debug(
"Will reconnect and resend because delayed QUIT was sent."
);
if (isSocketOpen) {
this.close(true); // Ensure socket is not "open" when connect() called.
}
this.isRetry = true; // Set this so sending gets re-init'd in onIdle
this._isDelayedQuitSent = false;
this.connect();
} else if (str != "QUIT") {
// Connection/socket is down and delayed QUIT was not previously sent.
// Just log this as a warning unless the current command is QUIT (which
// can occur, e.g., in _onError() when all cached connections are closed).
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": {
this.logger.debug("Authentication via AUTH XOAUTH2");
this._currentAction = this._actionAUTH_XOAUTH2;
const 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");
// Don't send first token until we get a 334 continuation response.
// This avoids sending a line that is possibly rejected as too long.
this._sendCommand("AUTH GSSAPI", 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)
? "smtpHintAuthEncryptToPlainSsl"
: "smtpHintAuthEncryptToPlainNoSsl";
} else if (
this._server.authMethod == Ci.nsMsgAuthMethod.passwordCleartext &&
this._supportedAuthMethods.includes("CRAM-MD5")
) {
// Pref has plaintext password, server claims to support encrypted
// password.
err = "smtpHintAuthPlainToEncrypt";
} else {
err = "smtpAuthMechNotSupported";
}
this._onNsError(err);
return;
}
}
// Ask user what to do.
const action = this._authenticator.promptAuthFailed();
if (action == 1) {
// Cancel button pressed.
this.logger.error(`Authentication failed: ${command.data}`);
this._onNsError("smtpAuthFailure");
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() {
const helloArgument = this._server.helloArgument;
if (helloArgument) {
return helloArgument;
}
try {
// The address format follows rfc5321#section-4.1.3.
const netAddr = this.socket?.transport.getScriptableSelfAddr();
const 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("smtpServerError", 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("smtpServerError", 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("startTlsFailed", this._server.hostname);
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("smtpServerError", command.data);
return;
}
this._supportedAuthMethods = [];
const lines = command.data.toUpperCase().split("\n");
// Skip the first greeting line.
for (const 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("startTlsFailed", this._server.hostname);
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("smtpServerError", 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("smtpServerError", 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("smtpServerError", 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("smtpAuthFailure", 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("smtpAuthFailure", 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("smtpAuthFailure", 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("smtpAuthGssapi", command.data);
return;
}
let token;
try {
token = this._authenticator.getNextGssapiToken(command.data);
} catch (e) {
this.logger.error(e);
this._actionAUTHComplete({ success: false, data: "AUTH GSSAPI" });
return;
}
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("smtpAuthFailure", command.data);
return;
}
const 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("smtpServerError", 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 error = "errorSendingFromCommand"; // default error message
if (command.statusCode == 552) {
// Too much mail data indicated by "size" parameter of MAIL FROM.
error = "smtpPermSizeExceeded2";
}
if (command.statusCode == 452 || command.statusCode == 451) {
error = "smtpTooManyRecipients";
}
this._onNsError(error, 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) {
const 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(
"errorSendingRcptCommand",
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) {
// some servers might use 250 instead
if (![250, 354].includes(command.statusCode)) {
this._onNsError("errorSendingDataCommand", 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) {
let reuseConnection = true; // Stays true for LMTP, may go false for SMTP.
if (this.options.lmtp) {
// LMTP returns a response code for *every* successfully set recipient
// For every recipient the message might succeed or fail individually
const 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();
} 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._numMessages++; // Number of messages sent on current connection.
// Obtain the messages per connection pref. If 0 or less, there is no
// limit imposed.
const msgsPerConn = this._server._getIntPrefWithDefault(
"max_messages_per_connection",
10
);
const messagesPerConnection = msgsPerConn > 0 ? msgsPerConn : 0;
// If recipient count has exceeded 99 or message count per connection, if
// enabled, has reached its limit, set reuseConnection flag false to cause
// QUIT to be sent by _free() below. The next messages will be sent on the
// next available SmtpClient instance with a new connection.
if (
this._rcptCount >= 100 ||
(messagesPerConnection > 0 &&
this._numMessages >= messagesPerConnection)
) {
reuseConnection = false;
}
// If reuseConnection is set false above, don't start the QUIT timer
// below since the connection will be closed and a new connection
// established (using next available SmtpClient instance).
// If reuseConnection is true, the timer will be started if the command
// was successful (no retry needed). If timer is started, on timerout it
// will send QUIT if the connection remains open. If another message is
// sent before the timeout, the timer will be cleared so QUIT is not sent.
if (reuseConnection && command.success) {
let delayInMs = this._server._getIntPrefWithDefault(
"quit_delay_ms",
5000
);
delayInMs = delayInMs > 0 ? delayInMs : 0;
this._quitTimer = setTimeout(() => {
this._quitTimer = null;
if (this.socket?.readyState == "open") {
this.quit();
this._isDelayedQuitSent = true; // Must be set after _sendCommand
}
}, delayInMs);
}
this._currentAction = this._actionIdle;
if (command.success) {
this.ondone();
} else {
this._onNsError("errorSendingMessage", command.data);
}
}
this._free(!reuseConnection); // Send quit only if NOT reusing connection.
}
}