node-red-node-email
Version:
Node-RED nodes to send and receive simple emails.
1,455 lines (1,231 loc) • 50.9 kB
JavaScript
'use strict';
const SMTPStream = require('./smtp-stream').SMTPStream;
const dns = require('dns');
const tls = require('tls');
const net = require('net');
const ipv6normalize = require('ipv6-normalize');
const sasl = require('./sasl');
const crypto = require('crypto');
const os = require('os');
const punycode = require('punycode.js');
const EventEmitter = require('events');
const base32 = require('base32.js');
const SOCKET_TIMEOUT = 60 * 1000;
/**
* Creates a handler for new socket
*
* @constructor
* @param {Object} server Server instance
* @param {Object} socket Socket instance
*/
class SMTPConnection extends EventEmitter {
constructor(server, socket, options) {
super();
options = options || {};
// Random session ID, used for logging
this.id = options.id || base32.encode(crypto.randomBytes(10)).toLowerCase();
this.ignore = options.ignore;
this._server = server;
this._socket = socket;
// session data (envelope, user etc.)
this.session = this.session = {
id: this.id
};
// how many messages have been processed
this._transactionCounter = 0;
// Do not allow input from client until initial greeting has been sent
this._ready = false;
// If true then the connection is currently being upgraded to TLS
this._upgrading = false;
// Set handler for incoming command and handler bypass detection by command name
this._nextHandler = false;
// Parser instance for the incoming stream
this._parser = new SMTPStream();
// Set handler for incoming commands
this._parser.oncommand = (...args) => this._onCommand(...args);
// if currently in data mode, this stream gets the content of incoming message
this._dataStream = false;
// If true, then the connection is using TLS
this.session.secure = this.secure = !!this._server.options.secure;
this.needsUpgrade = !!this._server.options.needsUpgrade;
this.tlsOptions = this.secure && !this.needsUpgrade && this._socket.getCipher ? this._socket.getCipher() : false;
// Store local and remote addresses for later usage
this.localAddress = (options.localAddress || this._socket.localAddress || '').replace(/^::ffff:/, '');
this.localPort = Number(options.localPort || this._socket.localPort) || 0;
this.remoteAddress = (options.remoteAddress || this._socket.remoteAddress || '').replace(/^::ffff:/, '');
this.remotePort = Number(options.remotePort || this._socket.remotePort) || 0;
// normalize IPv6 addresses
if (this.localAddress && net.isIPv6(this.localAddress)) {
this.localAddress = ipv6normalize(this.localAddress);
}
if (this.remoteAddress && net.isIPv6(this.remoteAddress)) {
this.remoteAddress = ipv6normalize(this.remoteAddress);
}
// Error counter - if too many commands in non-authenticated state are used, then disconnect
this._unauthenticatedCommands = 0;
// Max allowed unauthenticated commands
this._maxAllowedUnauthenticatedCommands = this._server.options.maxAllowedUnauthenticatedCommands || 10;
// Error counter - if too many invalid commands are used, then disconnect
this._unrecognizedCommands = 0;
// Server hostname for the greegins
this.name = this._server.options.name || os.hostname();
// Resolved hostname for remote IP address
this.clientHostname = false;
// The opening SMTP command (HELO, EHLO or LHLO)
this.openingCommand = false;
// The hostname client identifies itself with
this.hostNameAppearsAs = false;
// data passed from XCLIENT command
this._xClient = new Map();
// data passed from XFORWARD command
this._xForward = new Map();
// if true then can emit connection info
this._canEmitConnection = true;
// increment connection count
this._closing = false;
this._closed = false;
}
/**
* Initiates the connection. Checks connection limits and reverse resolves client hostname. The client
* is not allowed to send anything before init has finished otherwise 'You talk too soon' error is returned
*/
init() {
// Setup event handlers for the socket
this._setListeners(() => {
// Check that connection limit is not exceeded
if (this._server.options.maxClients && this._server.connections.size > this._server.options.maxClients) {
return this.send(421, this.name + ' Too many connected clients, try again in a moment');
}
// Keep a small delay for detecting early talkers
setTimeout(() => this.connectionReady(), 100);
});
}
connectionReady(next) {
// Resolve hostname for the remote IP
let reverseCb = (err, hostnames) => {
if (err) {
this._server.logger.error(
{
tnx: 'connection',
cid: this.id,
host: this.remoteAddress,
hostname: this.clientHostname,
err
},
'Reverse resolve for %s: %s',
this.remoteAddress,
err.message
);
// ignore resolve error
}
if (this._closing || this._closed) {
return;
}
this.clientHostname = (hostnames && hostnames.shift()) || '[' + this.remoteAddress + ']';
this._resetSession();
let onSecureIfNeeded = next => {
if (!this.session.secure) {
// no TLS
return next();
}
this.session.servername = this._socket.servername;
this._server.onSecure(this._socket, this.session, err => {
if (err) {
return this._onError(err);
}
next();
});
};
this._server.onConnect(this.session, err => {
this._server.logger.info(
{
tnx: 'connection',
cid: this.id,
host: this.remoteAddress,
hostname: this.clientHostname
},
'Connection from %s',
this.clientHostname
);
if (err) {
this.send(err.responseCode || 554, err.message);
return this.close();
}
onSecureIfNeeded(() => {
this._ready = true; // Start accepting data from input
if (!this._server.options.useXClient && !this._server.options.useXForward) {
this.emitConnection();
}
this.send(
220,
this.name +
' ' +
(this._server.options.lmtp ? 'LMTP' : 'ESMTP') +
(this._server.options.banner ? ' ' + this._server.options.banner : '')
);
if (typeof next === 'function') {
next();
}
});
});
};
// Skip reverse name resolution if disabled.
if (this._server.options.disableReverseLookup) {
return reverseCb(null, false);
}
// also make sure that we do not wait too long over the reverse resolve call
let greetingSent = false;
let reverseTimer = setTimeout(() => {
clearTimeout(reverseTimer);
if (greetingSent) {
return;
}
greetingSent = true;
reverseCb(new Error('Timeout'));
}, 1500);
try {
// dns.reverse throws on invalid input, see https://github.com/nodejs/node/issues/3112
dns.reverse(this.remoteAddress.toString(), (...args) => {
clearTimeout(reverseTimer);
if (greetingSent) {
return;
}
greetingSent = true;
reverseCb(...args);
});
} catch (E) {
clearTimeout(reverseTimer);
if (greetingSent) {
return;
}
greetingSent = true;
reverseCb(E);
}
}
/**
* Send data to socket
*
* @param {Number} code Response code
* @param {String|Array} data If data is Array, send a multi-line response
*/
send(code, data) {
let payload;
if (Array.isArray(data)) {
payload = data.map((line, i, arr) => code + (i < arr.length - 1 ? '-' : ' ') + line).join('\r\n');
} else {
payload = []
.concat(code || [])
.concat(data || [])
.join(' ');
}
if (code >= 400) {
this.session.error = payload;
}
// Ref. https://datatracker.ietf.org/doc/html/rfc4954#section-4
if (code === 334 && payload === '334') {
payload += ' ';
}
if (this._socket && !this._socket.destroyed && this._socket.readyState === 'open') {
this._socket.write(payload + '\r\n');
this._server.logger.debug(
{
tnx: 'send',
cid: this.id,
user: (this.session.user && this.session.user.username) || this.session.user
},
'S:',
payload
);
}
if (code === 421) {
this.close();
}
}
/**
* Close socket
*/
close() {
if (!this._socket.destroyed && this._socket.writable) {
this._socket.end();
}
this._server.connections.delete(this);
this._closing = true;
}
// PRIVATE METHODS
/**
* Setup socket event handlers
*/
_setListeners(callback) {
this._socket.on('close', hadError => this._onCloseEvent(hadError));
this._socket.on('error', err => this._onError(err));
this._socket.setTimeout(this._server.options.socketTimeout || SOCKET_TIMEOUT, () => this._onTimeout());
this._socket.pipe(this._parser);
if (!this.needsUpgrade) {
return callback();
}
this.upgrade(() => false, callback);
}
_onCloseEvent(hadError) {
this._server.logger.info(
{
tnx: 'close',
cid: this.id,
host: this.remoteAddress,
user: (this.session.user && this.session.user.username) || this.session.user,
hadError
},
'%s received "close" event from %s' + (hadError ? ' after error' : ''),
this.id,
this.remoteAddress
);
this._onClose();
}
/**
* Fired when the socket is closed
* @event
*/
_onClose(/* hadError */) {
if (this._parser) {
this._parser.isClosed = true;
this._socket.unpipe(this._parser);
this._parser = false;
}
if (this._dataStream) {
this._dataStream.unpipe();
this._dataStream = null;
}
this._server.connections.delete(this);
if (this._closed) {
return;
}
this._closed = true;
this._closing = false;
this._server.logger.info(
{
tnx: 'close',
cid: this.id,
host: this.remoteAddress,
user: (this.session.user && this.session.user.username) || this.session.user
},
'Connection closed to %s',
this.clientHostname || this.remoteAddress
);
setImmediate(() => this._server.onClose(this.session));
}
/**
* Fired when an error occurs with the socket
*
* @event
* @param {Error} err Error object
*/
_onError(err) {
err.remote = this.remoteAddress;
this._server.logger.error(
{
err,
tnx: 'error',
user: (this.session.user && this.session.user.username) || this.session.user
},
'%s %s %s',
this.id,
this.remoteAddress,
err.message
);
if ((err.code === 'ECONNRESET' || err.code === 'EPIPE') && (!this.session.envelope || !this.session.envelope.mailFrom)) {
// We got a connection error outside transaction. In most cases it means dirty
// connection ending by the other party, so we can just ignore it
this.close(); // mark connection as 'closing'
return;
}
this.emit('error', err);
}
/**
* Fired when socket timeouts. Closes connection
*
* @event
*/
_onTimeout() {
this.send(421, 'Timeout - closing connection');
}
/**
* Checks if a selected command is available and ivokes it
*
* @param {Buffer} command Single line of data from the client
* @param {Function} callback Callback to run once the command is processed
*/
_onCommand(command, callback) {
let commandName = (command || '').toString().split(' ').shift().toUpperCase();
this._server.logger.debug(
{
tnx: 'command',
cid: this.id,
command: commandName,
user: (this.session.user && this.session.user.username) || this.session.user
},
'C:',
(command || '').toString()
);
let handler;
if (!this._ready) {
// block spammers that send payloads before server greeting
return this.send(421, this.name + ' You talk too soon');
}
// block malicious web pages that try to make SMTP calls from an AJAX request
if (/^(OPTIONS|GET|HEAD|POST|PUT|DELETE|TRACE|CONNECT) \/.* HTTP\/\d\.\d$/i.test(command)) {
return this.send(421, 'HTTP requests not allowed');
}
callback = callback || (() => false);
if (this._upgrading) {
// ignore any commands before TLS upgrade is finished
return callback();
}
if (this._nextHandler) {
// If we already have a handler method queued up then use this
handler = this._nextHandler;
this._nextHandler = false;
} else {
// detect handler from the command name
switch (commandName) {
case 'HELO':
case 'EHLO':
case 'LHLO':
this.openingCommand = commandName;
break;
}
if (this._server.options.lmtp) {
switch (commandName) {
case 'HELO':
case 'EHLO':
this.send(500, 'Error: ' + commandName + ' not allowed in LMTP server');
return setImmediate(callback);
case 'LHLO':
commandName = 'EHLO';
break;
}
}
if (this._isSupported(commandName)) {
handler = this['handler_' + commandName];
}
}
if (!handler) {
// if the user makes more
this._unrecognizedCommands++;
if (this._unrecognizedCommands >= 10) {
return this.send(421, 'Error: too many unrecognized commands');
}
this.send(500, 'Error: command not recognized');
return setImmediate(callback);
}
// block users that try to fiddle around without logging in
if (
!this.session.user &&
this._isSupported('AUTH') &&
!this._server.options.authOptional &&
commandName !== 'AUTH' &&
this._maxAllowedUnauthenticatedCommands !== false
) {
this._unauthenticatedCommands++;
if (this._unauthenticatedCommands >= this._maxAllowedUnauthenticatedCommands) {
return this.send(421, 'Error: too many unauthenticated commands');
}
}
if (!this.hostNameAppearsAs && commandName && ['MAIL', 'RCPT', 'DATA', 'AUTH'].includes(commandName)) {
this.send(503, 'Error: send ' + (this._server.options.lmtp ? 'LHLO' : 'HELO/EHLO') + ' first');
return setImmediate(callback);
}
// Check if authentication is required
if (!this.session.user && this._isSupported('AUTH') && ['MAIL', 'RCPT', 'DATA'].includes(commandName) && !this._server.options.authOptional) {
this.send(
530,
typeof this._server.options.authRequiredMessage === 'string' ? this._server.options.authRequiredMessage : 'Error: authentication Required'
);
return setImmediate(callback);
}
handler.call(this, command, callback);
}
/**
* Checks that a command is available and is not listed in the disabled commands array
*
* @param {String} command Command name
* @returns {Boolean} Returns true if the command can be used
*/
_isSupported(command) {
command = (command || '').toString().trim().toUpperCase();
return !this._server.options.disabledCommands.includes(command) && typeof this['handler_' + command] === 'function';
}
/**
* Parses commands like MAIL FROM and RCPT TO. Returns an object with the address and optional arguments.
*
* @param {[type]} name Address type, eg 'mail from' or 'rcpt to'
* @param {[type]} command Data payload to parse
* @returns {Object|Boolean} Parsed address in the form of {address:, args: {}} or false if parsing failed
*/
_parseAddressCommand(name, command) {
command = (command || '').toString();
name = (name || '').toString().trim().toUpperCase();
let parts = command.split(':');
command = parts.shift().trim().toUpperCase();
parts = parts.join(':').trim().split(/\s+/);
let address = parts.shift();
let args = false;
let invalid = false;
if (name !== command) {
return false;
}
if (!/^<[^<>]*>$/.test(address)) {
invalid = true;
} else {
address = address.substr(1, address.length - 2);
}
parts.forEach(part => {
part = part.split('=');
let key = part.shift().toUpperCase();
let value = part.join('=') || true;
if (typeof value === 'string') {
// decode 'xtext'
value = value.replace(/\+([0-9A-F]{2})/g, (match, hex) => unescape('%' + hex));
}
if (!args) {
args = {};
}
args[key] = value;
});
if (address) {
// enforce unycode
address = address.split('@');
if (address.length !== 2 || !address[0] || !address[1]) {
// really bad e-mail address validation. was not able to use joi because of the missing unicode support
invalid = true;
} else {
try {
address = [address[0] || '', '@', punycode.toUnicode(address[1] || '')].join('');
} catch (E) {
this._server.logger.error(
{
tnx: 'punycode',
cid: this.id,
user: (this.session.user && this.session.user.username) || this.session.user
},
'Failed to process punycode domain "%s". error=%s',
address[1],
E.message
);
address = [address[0] || '', '@', address[1] || ''].join('');
}
}
}
return invalid
? false
: {
address,
args
};
}
/**
* Resets or sets up a new session. We reuse existing session object to keep
* application specific data.
*/
_resetSession() {
let session = this.session;
// reset data that might be overwritten
session.localAddress = this.localAddress;
session.localPort = this.localPort;
session.remoteAddress = this.remoteAddress;
session.remotePort = this.remotePort;
session.clientHostname = this.clientHostname;
session.openingCommand = this.openingCommand;
session.hostNameAppearsAs = this.hostNameAppearsAs;
session.xClient = this._xClient;
session.xForward = this._xForward;
session.transmissionType = this._transmissionType();
session.tlsOptions = this.tlsOptions;
// reset transaction properties
session.envelope = {
mailFrom: false,
rcptTo: []
};
session.transaction = this._transactionCounter + 1;
}
/**
* Returns current transmission type
*
* @return {String} Transmission type
*/
_transmissionType() {
let type = this._server.options.lmtp ? 'LMTP' : 'SMTP';
if (this.openingCommand === 'EHLO') {
type = 'E' + type;
}
if (this.secure) {
type += 'S';
}
if (this.session.user) {
type += 'A';
}
return type;
}
emitConnection() {
if (!this._canEmitConnection) {
return;
}
this._canEmitConnection = false;
this.emit('connect', {
id: this.id,
localAddress: this.localAddress,
localPort: this.localPort,
remoteAddress: this.remoteAddress,
remotePort: this.remotePort,
hostNameAppearsAs: this.hostNameAppearsAs,
clientHostname: this.clientHostname
});
}
// COMMAND HANDLERS
/**
* Processes EHLO. Requires valid hostname as the single argument.
*/
handler_EHLO(command, callback) {
let parts = command.toString().trim().split(/\s+/);
let hostname = parts[1] || '';
if (parts.length !== 2) {
this.send(501, 'Error: syntax: ' + (this._server.options.lmtp ? 'LHLO' : 'EHLO') + ' hostname');
return callback();
}
this.hostNameAppearsAs = hostname.toLowerCase();
let features = ['PIPELINING', '8BITMIME', 'SMTPUTF8'].filter(feature => !this._server.options['hide' + feature]);
if (this._server.options.authMethods.length && this._isSupported('AUTH') && !this.session.user) {
features.push(['AUTH'].concat(this._server.options.authMethods).join(' '));
}
if (!this.secure && this._isSupported('STARTTLS') && !this._server.options.hideSTARTTLS) {
features.push('STARTTLS');
}
if (this._server.options.size) {
features.push('SIZE' + (this._server.options.hideSize ? '' : ' ' + this._server.options.size));
}
// XCLIENT ADDR removes any special privileges for the client
if (!this._xClient.has('ADDR') && this._server.options.useXClient && this._isSupported('XCLIENT')) {
features.push('XCLIENT NAME ADDR PORT PROTO HELO LOGIN');
}
// If client has already issued XCLIENT ADDR then it does not have privileges for XFORWARD anymore
if (!this._xClient.has('ADDR') && this._server.options.useXForward && this._isSupported('XFORWARD')) {
features.push('XFORWARD NAME ADDR PORT PROTO HELO IDENT SOURCE');
}
this._resetSession(); // EHLO is effectively the same as RSET
this.send(250, [this.name + ' Nice to meet you, ' + this.clientHostname].concat(features || []));
callback();
}
/**
* Processes HELO. Requires valid hostname as the single argument.
*/
handler_HELO(command, callback) {
let parts = command.toString().trim().split(/\s+/);
let hostname = parts[1] || '';
if (parts.length !== 2) {
this.send(501, 'Error: Syntax: HELO hostname');
return callback();
}
this.hostNameAppearsAs = hostname.toLowerCase();
this._resetSession(); // HELO is effectively the same as RSET
this.send(250, this.name + ' Nice to meet you, ' + this.clientHostname);
callback();
}
/**
* Processes QUIT. Closes the connection
*/
handler_QUIT(command, callback) {
this.send(221, 'Bye');
this.close();
callback();
}
/**
* Processes NOOP. Does nothing but keeps the connection alive
*/
handler_NOOP(command, callback) {
this.send(250, 'OK');
callback();
}
/**
* Processes RSET. Resets user and session info
*/
handler_RSET(command, callback) {
this._resetSession();
this.send(250, 'Flushed');
callback();
}
/**
* Processes HELP. Responds with url to RFC
*/
handler_HELP(command, callback) {
this.send(214, 'See https://tools.ietf.org/html/rfc5321 for details');
callback();
}
/**
* Processes VRFY. Does not verify anything
*/
handler_VRFY(command, callback) {
this.send(252, 'Try to send something. No promises though');
callback();
}
/**
* Overrides connection info
* http://www.postfix.org/XCLIENT_README.html
*
* TODO: add unit tests
*/
handler_XCLIENT(command, callback) {
// check if user is authorized to perform this command
if (this._xClient.has('ADDR') || !this._server.options.useXClient) {
this.send(550, 'Error: Not allowed');
return callback();
}
// not allowed to change properties if already processing mail
if (this.session.envelope.mailFrom) {
this.send(503, 'Error: Mail transaction in progress');
return callback();
}
let allowedKeys = ['NAME', 'ADDR', 'PORT', 'PROTO', 'HELO', 'LOGIN'];
let parts = command.toString().trim().split(/\s+/);
let key, value;
let data = new Map();
parts.shift(); // remove XCLIENT prefix
if (!parts.length) {
this.send(501, 'Error: Bad command parameter syntax');
return callback();
}
let loginValue = false;
// parse and validate arguments
for (let i = 0, len = parts.length; i < len; i++) {
value = parts[i].split('=');
key = value.shift();
if (value.length !== 1 || !allowedKeys.includes(key.toUpperCase())) {
this.send(501, 'Error: Bad command parameter syntax');
return callback();
}
key = key.toUpperCase();
// value is xtext
value = (value[0] || '').replace(/\+([0-9A-F]{2})/g, (match, hex) => unescape('%' + hex));
if (['[UNAVAILABLE]', '[TEMPUNAVAIL]'].includes(value.toUpperCase())) {
value = false;
}
if (data.has(key)) {
// ignore duplicate keys
continue;
}
data.set(key, value);
switch (key) {
// handled outside the switch
case 'LOGIN':
loginValue = value;
break;
case 'ADDR':
if (value) {
value = value.replace(/^IPV6:/i, ''); // IPv6 addresses are prefixed with "IPv6:"
if (!net.isIP(value)) {
this.send(501, 'Error: Bad command parameter syntax. Invalid address');
return callback();
}
if (net.isIPv6(value)) {
value = ipv6normalize(value);
}
this._server.logger.info(
{
tnx: 'xclient',
cid: this.id,
xclientKey: 'ADDR',
xclient: value,
user: (this.session.user && this.session.user.username) || this.session.user
},
'XCLIENT from %s through %s',
value,
this.remoteAddress
);
// store original value for reference as ADDR:DEFAULT
if (!this._xClient.has('ADDR:DEFAULT')) {
this._xClient.set('ADDR:DEFAULT', this.remoteAddress);
}
this.remoteAddress = value;
this.hostNameAppearsAs = false; // reset client provided hostname, require HELO/EHLO
}
break;
case 'NAME':
value = value || '';
this._server.logger.info(
{
tnx: 'xclient',
cid: this.id,
xclientKey: 'NAME',
xclient: value,
user: (this.session.user && this.session.user.username) || this.session.user
},
'XCLIENT hostname resolved as "%s"',
value
);
// store original value for reference as NAME:DEFAULT
if (!this._xClient.has('NAME:DEFAULT')) {
this._xClient.set('NAME:DEFAULT', this.clientHostname || '');
}
this.clientHostname = value.toLowerCase();
break;
case 'PORT':
value = Number(value) || '';
this._server.logger.info(
{
tnx: 'xclient',
cid: this.id,
xclientKey: 'PORT',
xclient: value,
user: (this.session.user && this.session.user.username) || this.session.user
},
'XCLIENT remote port resolved as "%s"',
value
);
// store original value for reference as NAME:DEFAULT
if (!this._xClient.has('PORT:DEFAULT')) {
this._xClient.set('PORT:DEFAULT', this.remotePort || '');
}
this.remotePort = value;
break;
default:
// other values are not relevant
}
this._xClient.set(key, value);
}
let checkLogin = done => {
if (typeof loginValue !== 'string') {
return done();
}
if (!loginValue) {
// clear authentication session?
this._server.logger.info(
{
tnx: 'deauth',
cid: this.id,
user: (this.session.user && this.session.user.username) || this.session.user
},
'User deauthenticated using %s',
'XCLIENT'
);
this.session.user = false;
return done();
}
let method = 'SASL_XCLIENT';
sasl[method].call(this, [loginValue], err => {
if (err) {
this.send(550, err.message);
this.close();
return;
}
done();
});
};
// Use [ADDR] if NAME was empty
if (this.remoteAddress && !this.clientHostname) {
this.clientHostname = '[' + this.remoteAddress + ']';
}
if (data.has('ADDR')) {
this.emitConnection();
}
checkLogin(() => {
// success
this.send(
220,
this.name + ' ' + (this._server.options.lmtp ? 'LMTP' : 'ESMTP') + (this._server.options.banner ? ' ' + this._server.options.banner : '')
);
callback();
});
}
/**
* Processes XFORWARD data
* http://www.postfix.org/XFORWARD_README.html
*
* TODO: add unit tests
*/
handler_XFORWARD(command, callback) {
// check if user is authorized to perform this command
if (!this._server.options.useXForward) {
this.send(550, 'Error: Not allowed');
return callback();
}
// not allowed to change properties if already processing mail
if (this.session.envelope.mailFrom) {
this.send(503, 'Error: Mail transaction in progress');
return callback();
}
let allowedKeys = ['NAME', 'ADDR', 'PORT', 'PROTO', 'HELO', 'IDENT', 'SOURCE'];
let parts = command.toString().trim().split(/\s+/);
let key, value;
let data = new Map();
let hasAddr = false;
parts.shift(); // remove XFORWARD prefix
if (!parts.length) {
this.send(501, 'Error: Bad command parameter syntax');
return callback();
}
// parse and validate arguments
for (let i = 0, len = parts.length; i < len; i++) {
value = parts[i].split('=');
key = value.shift();
if (value.length !== 1 || !allowedKeys.includes(key.toUpperCase())) {
this.send(501, 'Error: Bad command parameter syntax');
return callback();
}
key = key.toUpperCase();
if (data.has(key)) {
// ignore duplicate keys
continue;
}
// value is xtext
value = (value[0] || '').replace(/\+([0-9A-F]{2})/g, (match, hex) => unescape('%' + hex));
if (value.toUpperCase() === '[UNAVAILABLE]') {
value = false;
}
data.set(key, value);
switch (key) {
case 'ADDR':
if (value) {
value = value.replace(/^IPV6:/i, ''); // IPv6 addresses are prefixed with "IPv6:"
if (!net.isIP(value)) {
this.send(501, 'Error: Bad command parameter syntax. Invalid address');
return callback();
}
if (net.isIPv6(value)) {
value = ipv6normalize(value);
}
this._server.logger.info(
{
tnx: 'xforward',
cid: this.id,
xforwardKey: 'ADDR',
xforward: value,
user: (this.session.user && this.session.user.username) || this.session.user
},
'XFORWARD from %s through %s',
value,
this.remoteAddress
);
// store original value for reference as ADDR:DEFAULT
if (!this._xClient.has('ADDR:DEFAULT')) {
this._xClient.set('ADDR:DEFAULT', this.remoteAddress);
}
hasAddr = true;
this.remoteAddress = value;
}
break;
case 'NAME':
value = value || '';
this._server.logger.info(
{
tnx: 'xforward',
cid: this.id,
xforwardKey: 'NAME',
xforward: value,
user: (this.session.user && this.session.user.username) || this.session.user
},
'XFORWARD hostname resolved as "%s"',
value
);
this.clientHostname = value.toLowerCase();
break;
case 'PORT':
value = Number(value) || 0;
this._server.logger.info(
{
tnx: 'xforward',
cid: this.id,
xforwardKey: 'PORT',
xforward: value,
user: (this.session.user && this.session.user.username) || this.session.user
},
'XFORWARD port resolved as "%s"',
value
);
this.remotePort = value;
break;
case 'HELO':
value = (value || '').toString().toLowerCase();
this._server.logger.info(
{
tnx: 'xforward',
cid: this.id,
xforwardKey: 'HELO',
xforward: value,
user: (this.session.user && this.session.user.username) || this.session.user
},
'XFORWARD HELO name resolved as "%s"',
value
);
this.hostNameAppearsAs = value;
break;
default:
// other values are not relevant
}
this._xForward.set(key, value);
}
if (hasAddr) {
this._canEmitConnection = true;
this.emitConnection();
}
// success
this.send(250, 'OK');
callback();
}
/**
* Upgrades connection to TLS if possible
*/
handler_STARTTLS(command, callback) {
if (this.secure) {
this.send(503, 'Error: TLS already active');
return callback();
}
this.send(220, 'Ready to start TLS');
this.upgrade(callback);
}
/**
* Check if selected authentication is available and delegate auth data to SASL
*/
handler_AUTH(command, callback) {
let args = command.toString().trim().split(/\s+/);
let method;
let handler;
args.shift(); // remove AUTH
method = (args.shift() || '').toString().toUpperCase(); // get METHOD and keep additional arguments in the array
handler = sasl['SASL_' + method];
handler = handler ? handler.bind(this) : handler;
if (!this.secure && this._isSupported('STARTTLS') && !this._server.options.hideSTARTTLS && !this._server.options.allowInsecureAuth) {
this.send(538, 'Error: Must issue a STARTTLS command first');
return callback();
}
if (this.session.user) {
this.send(503, 'Error: No identity changes permitted');
return callback();
}
if (!this._server.options.authMethods.includes(method) || typeof handler !== 'function') {
this.send(504, 'Error: Unrecognized authentication type');
return callback();
}
handler(args, callback);
}
/**
* Processes MAIL FROM command, parses address and extra arguments
*/
handler_MAIL(command, callback) {
let parsed = this._parseAddressCommand('mail from', command);
// in case we still haven't informed about the new connection emit it
this.emitConnection();
// sender address can be empty, so we only check if parsing failed or not
if (!parsed) {
this.send(501, 'Error: Bad sender address syntax');
return callback();
}
if (this.session.envelope.mailFrom) {
this.send(503, 'Error: nested MAIL command');
return callback();
}
if (!this._server.options.hideSize && this._server.options.size && parsed.args.SIZE && Number(parsed.args.SIZE) > this._server.options.size) {
this.send(552, 'Error: message exceeds fixed maximum message size ' + this._server.options.size);
return callback();
}
this._server.onMailFrom(parsed, this.session, err => {
if (err) {
this.send(err.responseCode || 550, err.message);
return callback();
}
this.session.envelope.mailFrom = parsed;
this.send(250, 'Accepted');
callback();
});
}
/**
* Processes RCPT TO command, parses address and extra arguments
*/
handler_RCPT(command, callback) {
let parsed = this._parseAddressCommand('rcpt to', command);
// recipient address can not be empty
if (!parsed || !parsed.address) {
this.send(501, 'Error: Bad recipient address syntax');
return callback();
}
if (!this.session.envelope.mailFrom) {
this.send(503, 'Error: need MAIL command');
return callback();
}
this._server.onRcptTo(parsed, this.session, err => {
if (err) {
this.send(err.responseCode || 550, err.message);
return callback();
}
// check if the address is already used, if so then overwrite
for (let i = 0, len = this.session.envelope.rcptTo.length; i < len; i++) {
if (this.session.envelope.rcptTo[i].address.toLowerCase() === parsed.address.toLowerCase()) {
this.session.envelope.rcptTo[i] = parsed;
parsed = false;
break;
}
}
if (parsed) {
this.session.envelope.rcptTo.push(parsed);
}
this.send(250, 'Accepted');
callback();
});
}
/**
* Processes DATA by forwarding incoming stream to the onData handler
*/
handler_DATA(command, callback) {
if (!this.session.envelope.rcptTo.length) {
this.send(503, 'Error: need RCPT command');
return callback();
}
if (!this._parser) {
return callback();
}
this._dataStream = this._parser.startDataMode(this._server.options.size);
let close = (err, message) => {
let i, len;
this._server.logger.debug(
{
tnx: 'data',
cid: this.id,
bytes: this._parser.dataBytes,
user: (this.session.user && this.session.user.username) || this.session.user
},
'C: <%s bytes of DATA>',
this._parser.dataBytes
);
if (typeof this._dataStream === 'object' && this._dataStream && this._dataStream.readable) {
this._dataStream.removeAllListeners();
}
if (err) {
if (this._server.options.lmtp) {
// separate error response for every recipient when using LMTP
for (i = 0, len = this.session.envelope.rcptTo.length; i < len; i++) {
this.send(err.responseCode || 450, err.message);
}
} else {
// single error response when using SMTP
this.send(err.responseCode || 450, err.message);
}
} else if (Array.isArray(message)) {
// separate responses for every recipient when using LMTP
message.forEach(response => {
if (/Error\]$/i.test(Object.prototype.toString.call(response))) {
this.send(response.responseCode || 450, response.message);
} else {
this.send(250, typeof response === 'string' ? response : 'OK: message accepted');
}
});
} else if (this._server.options.lmtp) {
// separate success response for every recipient when using LMTP
for (i = 0, len = this.session.envelope.rcptTo.length; i < len; i++) {
this.send(250, typeof message === 'string' ? message : 'OK: message accepted');
}
} else {
// single success response when using SMTP
this.send(250, typeof message === 'string' ? message : 'OK: message queued');
}
this._transactionCounter++;
this._unrecognizedCommands = 0; // reset unrecognized commands counter
this._resetSession(); // reset session state
if (typeof this._parser === 'object' && this._parser) {
this._parser.continue();
}
};
this._server.onData(this._dataStream, this.session, (err, message) => {
// ensure _dataStream is an object and not set to null by premature closing
// do not continue until the stream has actually ended
if (typeof this._dataStream === 'object' && this._dataStream && this._dataStream.readable) {
this._dataStream.on('end', () => close(err, message));
return;
}
close(err, message);
});
this.send(354, 'End data with <CR><LF>.<CR><LF>');
callback();
}
// Dummy handlers for some old sendmail specific commands
/**
* Processes sendmail WIZ command, upgrades to "wizard mode"
*/
handler_WIZ(command, callback) {
let args = command.toString().trim().split(/\s+/);
let password;
args.shift(); // remove WIZ
password = (args.shift() || '').toString();
// require password argument
if (!password) {
this.send(500, 'You are no wizard!');
return callback();
}
// all passwords pass validation, so everyone is a wizard!
this.session.isWizard = true;
this.send(200, 'Please pass, oh mighty wizard');
callback();
}
/**
* Processes sendmail SHELL command, should return interactive shell but this is a dummy function
* so no actual shell is provided to the client
*/
handler_SHELL(command, callback) {
this._server.logger.info(
{
tnx: 'shell',
cid: this.id,
user: (this.session.user && this.session.user.username) || this.session.user
},
'Client tried to invoke SHELL'
);
if (!this.session.isWizard) {
this.send(500, 'Mere mortals must not mutter that mantra');
return callback();
}
this.send(500, 'Error: Invoking shell is not allowed. This incident will be reported.');
callback();
}
/**
* Processes sendmail KILL command
*/
handler_KILL(command, callback) {
this._server.logger.info(
{
tnx: 'kill',
cid: this.id,
user: (this.session.user && this.session.user.username) || this.session.user
},
'Client tried to invoke KILL'
);
this.send(500, 'Can not kill Mom');
callback();
}
upgrade(callback, secureCallback) {
this._socket.unpipe(this._parser);
this._upgrading = true;
setImmediate(callback); // resume input stream
let secureContext = this._server.secureContext.get('*');
let socketOptions = {
secureContext,
isServer: true,
server: this._server.server,
SNICallback: this._server.options.SNICallback
};
// Apply additional socket options if these are set in the server options
['requestCert', 'rejectUnauthorized', 'NPNProtocols', 'SNICallback', 'session', 'requestOCSP'].forEach(key => {
if (key in this._server.options) {
socketOptions[key] = this._server.options[key];
}
});
// remove all listeners from the original socket besides the error handler
this._socket.removeAllListeners();
this._socket.on('error', err => this._onError(err));
// upgrade connection
let secureSocket = new tls.TLSSocket(this._socket, socketOptions);
secureSocket.once('close', hadError => this._onCloseEvent(hadError));
secureSocket.once('error', err => this._onError(err));
secureSocket.once('_tlsError', err => this._onError(err));
secureSocket.once('clientError', err => this._onError(err));
secureSocket.setTimeout(this._server.options.socketTimeout || SOCKET_TIMEOUT, () => this._onTimeout());
secureSocket.on('secure', () => {
this.session.secure = this.secure = true;
this._socket = secureSocket;
this._upgrading = false;
this.session.tlsOptions = this.tlsOptions = this._socket.getCipher();
this.session.servername = t