1936 lines
68 KiB
JavaScript
1936 lines
68 KiB
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 SOCKET_TIMEOUT = 60 * 1000;
|
|
|
|
// Enhanced Status Code mappings based on RFC 3463
|
|
const ENHANCED_STATUS_CODES = {
|
|
// Success codes (2xx)
|
|
200: '2.0.0', // System status, or system help reply
|
|
211: '2.0.0', // System status, or system help reply
|
|
214: '2.0.0', // Help message
|
|
220: '2.0.0', // Service ready
|
|
221: '2.0.0', // Service closing transmission channel
|
|
235: '2.7.0', // Authentication successful
|
|
250: '2.0.0', // Requested mail action okay, completed
|
|
251: '2.1.5', // User not local; will forward
|
|
252: '2.1.5', // Cannot VRFY user, but will accept message
|
|
334: '3.7.0', // Server challenge for authentication
|
|
354: '2.0.0', // Start mail input; end with <CRLF>.<CRLF>
|
|
|
|
// Temporary failure codes (4xx)
|
|
420: '4.4.2', // Timeout or connection lost (non-standard, used by some servers)
|
|
421: '4.4.2', // Service not available, closing transmission channel
|
|
450: '4.2.1', // Requested mail action not taken: mailbox unavailable
|
|
451: '4.3.0', // Requested action aborted: local error in processing
|
|
452: '4.2.2', // Requested action not taken: insufficient system storage
|
|
454: '4.7.0', // Temporary authentication failure
|
|
|
|
// Permanent failure codes (5xx)
|
|
500: '5.5.2', // Syntax error, command unrecognized
|
|
501: '5.5.4', // Syntax error in parameters or arguments
|
|
502: '5.5.1', // Command not implemented
|
|
503: '5.5.1', // Bad sequence of commands
|
|
504: '5.5.4', // Command parameter not implemented
|
|
521: '5.3.2', // Machine does not accept mail
|
|
523: '5.3.4', // Message size exceeds server limit (non-standard, used by some servers)
|
|
530: '5.7.0', // Authentication required
|
|
535: '5.7.8', // Authentication credentials invalid
|
|
538: '5.7.0', // Must issue a STARTTLS command first (non-standard)
|
|
550: '5.1.1', // Requested action not taken: mailbox unavailable
|
|
551: '5.1.6', // User not local; please try forwarding
|
|
552: '5.2.2', // Requested mail action aborted: exceeded storage allocation
|
|
553: '5.1.3', // Requested action not taken: mailbox name not allowed
|
|
554: '5.6.0', // Transaction failed
|
|
555: '5.5.4', // MAIL FROM/RCPT TO parameters not recognized or not implemented
|
|
556: '5.1.10', // RCPT TO syntax error (non-standard)
|
|
557: '5.7.1', // Delivery not authorized (non-standard, used by some servers)
|
|
558: '5.2.3' // Message too large for recipient (non-standard, used by some servers)
|
|
};
|
|
|
|
// Skip enhanced status codes for initial greeting and HELO/EHLO responses
|
|
const SKIPPED_COMMANDS_FOR_ENHANCED_STATUS_CODES = new Set(['HELO', 'EHLO', 'LHLO']);
|
|
|
|
// Context-specific enhanced status code mappings
|
|
const CONTEXTUAL_STATUS_CODES = {
|
|
// Mail transaction specific codes
|
|
MAIL_FROM_OK: '2.1.0', // Originator address valid
|
|
RCPT_TO_OK: '2.1.5', // Destination address valid
|
|
DATA_OK: '2.6.0', // Message accepted for delivery
|
|
|
|
// Authentication specific codes
|
|
AUTH_SUCCESS: '2.7.0', // Authentication successful
|
|
AUTH_REQUIRED: '5.7.0', // Authentication required
|
|
AUTH_INVALID: '5.7.8', // Authentication credentials invalid
|
|
|
|
// Policy specific codes
|
|
POLICY_VIOLATION: '5.7.1', // Delivery not authorized
|
|
SPAM_REJECTED: '5.7.1', // Message refused
|
|
|
|
// Mailbox specific codes
|
|
MAILBOX_FULL: '4.2.2', // Mailbox full
|
|
MAILBOX_NOT_FOUND: '5.1.1', // Mailbox does not exist
|
|
MAILBOX_SYNTAX_ERROR: '5.1.3', // Invalid mailbox syntax
|
|
|
|
// System specific codes
|
|
SYSTEM_ERROR: '4.3.0', // System error
|
|
SYSTEM_FULL: '4.3.1', // System storage exceeded
|
|
|
|
// Network specific codes
|
|
NETWORK_ERROR: '4.4.0', // Network routing error
|
|
CONNECTION_TIMEOUT: '4.4.2' // Connection timeout
|
|
};
|
|
|
|
/**
|
|
* 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 || BigInt('0x' + crypto.randomBytes(10).toString('hex')).toString(32).padStart(16, '0');
|
|
|
|
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({
|
|
maxCommandLength: this._server.options.maxCommandLength
|
|
});
|
|
|
|
// 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', false);
|
|
}
|
|
|
|
// Keep a small delay for detecting early talkers
|
|
let readyTimer = setTimeout(() => this.connectionReady(), 100);
|
|
// Unref timer so connection init delay doesn't prevent process exit
|
|
readyTimer.unref();
|
|
});
|
|
}
|
|
|
|
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, false);
|
|
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 : ''),
|
|
false
|
|
);
|
|
|
|
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);
|
|
// Unref timer so DNS timeout doesn't prevent process exit
|
|
reverseTimer.unref();
|
|
|
|
// Helper function to handle resolver results consistently
|
|
const handleResolverResult = (...args) => {
|
|
clearTimeout(reverseTimer);
|
|
if (greetingSent) {
|
|
return;
|
|
}
|
|
greetingSent = true;
|
|
reverseCb(...args);
|
|
};
|
|
|
|
try {
|
|
// Use custom resolver if provided, otherwise use default dns.reverse
|
|
if (this._server.options.resolver && typeof this._server.options.resolver.reverse === 'function') {
|
|
this._server.options.resolver.reverse(this.remoteAddress.toString(), handleResolverResult);
|
|
} else {
|
|
// dns.reverse throws on invalid input, see https://github.com/nodejs/node/issues/3112
|
|
dns.reverse(this.remoteAddress.toString(), handleResolverResult);
|
|
}
|
|
} 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
|
|
* @param {String|Boolean} context Optional context for enhanced status codes
|
|
*/
|
|
send(code, data, context) {
|
|
let payload;
|
|
let enhancedCode = this._getEnhancedStatusCode(code, context);
|
|
|
|
if (Array.isArray(data)) {
|
|
// Multi-line response - enhanced status code must appear on each line
|
|
payload = data
|
|
.map((line, i, arr) => {
|
|
let prefix = code + (i < arr.length - 1 ? '-' : ' ');
|
|
if (enhancedCode) {
|
|
prefix += enhancedCode + ' ';
|
|
}
|
|
return prefix + line;
|
|
})
|
|
.join('\r\n');
|
|
} else {
|
|
// Single line response
|
|
let parts = [code];
|
|
if (enhancedCode) {
|
|
parts.push(enhancedCode);
|
|
}
|
|
if (data) {
|
|
parts.push(data);
|
|
}
|
|
payload = parts.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._parser.on('error', err => {
|
|
this.send(421, 'Error: ' + err.message);
|
|
});
|
|
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.remoteAddress = 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 invokes 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;
|
|
callback = callback || (() => false);
|
|
|
|
// If server already closing then ignore commands
|
|
if (this._server._closeTimeout) {
|
|
return this.send(421, 'Server shutting down', commandName);
|
|
}
|
|
|
|
if (!this._ready) {
|
|
// block spammers that send payloads before server greeting
|
|
return this.send(421, this.name + ' You talk too soon', commandName);
|
|
}
|
|
|
|
// 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', commandName);
|
|
}
|
|
|
|
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', false);
|
|
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', commandName);
|
|
}
|
|
|
|
this.send(500, 'Error: command not recognized', commandName);
|
|
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', commandName);
|
|
}
|
|
}
|
|
|
|
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';
|
|
}
|
|
|
|
/**
|
|
* Determines if enhanced status codes should be used
|
|
* @returns {Boolean} True if enhanced status codes should be included in responses
|
|
*/
|
|
_useEnhancedStatusCodes() {
|
|
return !this._server.options.hideENHANCEDSTATUSCODES;
|
|
}
|
|
|
|
/**
|
|
* Gets the appropriate enhanced status code for a given SMTP response code and context
|
|
* @param {Number} code SMTP response code
|
|
* @param {String|Boolean} context Optional context for more specific status codes
|
|
* @returns {String} Enhanced status code or empty string if not applicable
|
|
*/
|
|
_getEnhancedStatusCode(code, context) {
|
|
if (context === false || !this._useEnhancedStatusCodes()) {
|
|
return '';
|
|
}
|
|
|
|
// Skip 3xx responses as per RFC 2034
|
|
if (code >= 300 && code < 400) {
|
|
return '';
|
|
}
|
|
|
|
// Skip enhanced status codes for initial greeting and HELO/EHLO responses
|
|
if (context && SKIPPED_COMMANDS_FOR_ENHANCED_STATUS_CODES.has(context)) {
|
|
return '';
|
|
}
|
|
|
|
// Use contextual codes if available
|
|
if (context && CONTEXTUAL_STATUS_CODES[context]) {
|
|
return CONTEXTUAL_STATUS_CODES[context];
|
|
}
|
|
|
|
// Use default mapping
|
|
if (ENHANCED_STATUS_CODES[code]) {
|
|
return ENHANCED_STATUS_CODES[code];
|
|
}
|
|
|
|
// 2xx fallback
|
|
if (code >= 200 && code < 300) {
|
|
return '2.0.0';
|
|
}
|
|
|
|
// 4xx (transient failure)
|
|
if (code >= 400 && code < 500) {
|
|
return '4.0.0';
|
|
}
|
|
|
|
// 5xx (permanent failure)
|
|
if (code >= 500) {
|
|
return '5.0.0';
|
|
}
|
|
|
|
// safeguard (non-spec; but should never occur)
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
|
|
// Skip parameters with empty keys
|
|
if (!key || key.trim() === '') {
|
|
return;
|
|
}
|
|
|
|
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) {
|
|
// Validate email address format
|
|
address = address.split('@');
|
|
if (address.length !== 2 || !address[0] || !address[1]) {
|
|
invalid = true;
|
|
} else {
|
|
let localPart = address[0];
|
|
let domain = address[1];
|
|
|
|
// RFC 5321 Section 4.5.3.1.3: Path max is 256 octets (including < @ >)
|
|
// This means local-part + @ + domain must be ≤ 253 octets
|
|
// Note: Individual limits (local 64, domain 255) are contradictory with path limit
|
|
// We enforce the path limit to accept Gmail CAF forwarding addresses
|
|
if (localPart.length + 1 + domain.length > 253) {
|
|
invalid = true;
|
|
} else {
|
|
// Validate local-part format - permissive validation
|
|
// Reject only clearly invalid patterns (dots at edges, consecutive dots)
|
|
// Allow common special characters used by email providers
|
|
if (localPart.startsWith('.') || localPart.endsWith('.') || localPart.includes('..')) {
|
|
invalid = true;
|
|
}
|
|
|
|
// Check if domain is an IP literal (RFC 5321 Section 4.1.3)
|
|
// Format: [IPv4] or [IPv6:address]
|
|
let isIPLiteral = !invalid && domain.startsWith('[') && domain.endsWith(']');
|
|
|
|
if (isIPLiteral) {
|
|
// IP literal address handling
|
|
// Extract the IP address from brackets
|
|
let ipContent = domain.slice(1, -1);
|
|
let isValidIP = false;
|
|
|
|
// Check for IPv6 format: IPv6:address
|
|
if (ipContent.toUpperCase().startsWith('IPV6:')) {
|
|
let ipv6Addr = ipContent.slice(5);
|
|
// Validate IPv6 address
|
|
if (net.isIPv6(ipv6Addr)) {
|
|
isValidIP = true;
|
|
// Normalize IPv6 address
|
|
try {
|
|
ipv6Addr = ipv6normalize(ipv6Addr);
|
|
domain = '[IPv6:' + ipv6Addr + ']';
|
|
} catch {
|
|
// Keep original if normalization fails
|
|
}
|
|
}
|
|
} else {
|
|
// Check for IPv4 format
|
|
if (net.isIPv4(ipContent)) {
|
|
isValidIP = true;
|
|
}
|
|
}
|
|
|
|
if (!isValidIP) {
|
|
invalid = true;
|
|
}
|
|
|
|
if (!invalid) {
|
|
// For IP literals, skip punycode conversion
|
|
address = [localPart, '@', domain].join('');
|
|
}
|
|
} else {
|
|
// Validate domain format (before punycode conversion)
|
|
// Reject clearly invalid patterns
|
|
if (
|
|
!invalid &&
|
|
(domain.startsWith('.') ||
|
|
domain.endsWith('.') ||
|
|
domain.includes('..') ||
|
|
domain.includes('.-') ||
|
|
domain.includes('-.') ||
|
|
!/^[a-zA-Z0-9\u0080-\uFFFF.-]+$/.test(domain))
|
|
) {
|
|
invalid = true;
|
|
}
|
|
|
|
if (!invalid) {
|
|
try {
|
|
address = [localPart, '@', punycode.toUnicode(domain)].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',
|
|
domain,
|
|
E.message
|
|
);
|
|
// If punycode conversion fails, treat as invalid
|
|
invalid = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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: [],
|
|
/** @property {boolean} requireTLS - RFC 8689: Indicates client requires TLS for entire delivery chain */
|
|
requireTLS: false,
|
|
/** @property {string} bodyType - RFC 6152: Message body encoding type (7bit or 8bitmime) */
|
|
bodyType: '7bit',
|
|
/** @property {boolean} smtpUtf8 - RFC 6531: Indicates UTF-8 support is requested */
|
|
smtpUtf8: false
|
|
};
|
|
|
|
if (!this._server.options.hideDSN)
|
|
session.envelope.dsn = {
|
|
ret: null, // RET parameter from MAIL FROM (FULL or HDRS)
|
|
envid: null // ENVID parameter from MAIL FROM
|
|
};
|
|
|
|
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', false);
|
|
return callback();
|
|
}
|
|
|
|
this.hostNameAppearsAs = hostname.toLowerCase();
|
|
|
|
let features = ['PIPELINING', '8BITMIME', 'SMTPUTF8', 'ENHANCEDSTATUSCODES', 'DSN'].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.secure && !this._server.options.hideREQUIRETLS) {
|
|
features.push('REQUIRETLS');
|
|
}
|
|
|
|
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
|
|
|
|
// Format HELO response using configured format or default
|
|
let heloResponse = this._server.options.heloResponse || '%s Nice to meet you, %s';
|
|
let replacements = [this.name, this.clientHostname];
|
|
let replacementIndex = 0;
|
|
let formattedResponse = heloResponse.replace(/%s/g, () => replacements[replacementIndex++] || '');
|
|
|
|
this.send(250, [formattedResponse].concat(features || []), false);
|
|
|
|
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', false);
|
|
return callback();
|
|
}
|
|
|
|
this.hostNameAppearsAs = hostname.toLowerCase();
|
|
|
|
this._resetSession(); // HELO is effectively the same as RSET
|
|
|
|
// Format HELO response using configured format or default
|
|
let heloResponse = this._server.options.heloResponse || '%s Nice to meet you, %s';
|
|
let replacements = [this.name, this.clientHostname];
|
|
let replacementIndex = 0;
|
|
let formattedResponse = heloResponse.replace(/%s/g, () => replacements[replacementIndex++] || '');
|
|
|
|
this.send(250, formattedResponse, false);
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Validates MAIL FROM parameters
|
|
* @param {Object} parsed - Parsed address command with args
|
|
* @returns {Object|null} Returns error object {code, message, enhancedCode} if validation fails, null if successful
|
|
*/
|
|
_validateMailParams(parsed) {
|
|
// Validate BODY parameter (RFC 6152 - 8BITMIME)
|
|
// Note: BINARYMIME is not supported as it requires BDAT command (RFC 3030)
|
|
if (parsed.args.BODY) {
|
|
const bodyType = parsed.args.BODY.toLowerCase();
|
|
if (bodyType !== '7bit' && bodyType !== '8bitmime') {
|
|
return {
|
|
code: 501,
|
|
message: 'Invalid BODY parameter value. Must be 7BIT or 8BITMIME',
|
|
enhancedCode: null
|
|
};
|
|
}
|
|
}
|
|
|
|
// Validate SMTPUTF8 parameter (RFC 6531)
|
|
if (parsed.args.SMTPUTF8 !== undefined) {
|
|
if (parsed.args.SMTPUTF8 !== true) {
|
|
return {
|
|
code: 501,
|
|
message: 'Invalid SMTPUTF8 parameter. This flag does not accept a value',
|
|
enhancedCode: null
|
|
};
|
|
}
|
|
}
|
|
|
|
// Validate REQUIRETLS parameter (RFC 8689)
|
|
if (parsed.args.REQUIRETLS !== undefined) {
|
|
if (!this.secure) {
|
|
return {
|
|
code: 530,
|
|
message: 'REQUIRETLS not permitted on non-TLS connections',
|
|
enhancedCode: null
|
|
};
|
|
}
|
|
if (parsed.args.REQUIRETLS !== true) {
|
|
return {
|
|
code: 501,
|
|
message: 'Invalid REQUIRETLS parameter. This flag does not accept a value',
|
|
enhancedCode: null
|
|
};
|
|
}
|
|
}
|
|
|
|
// Validate DSN parameters if DSN is supported
|
|
if (!this._server.options.hideDSN) {
|
|
if (parsed.args.RET) {
|
|
const ret = parsed.args.RET.toUpperCase();
|
|
if (ret !== 'FULL' && ret !== 'HDRS') {
|
|
return {
|
|
code: 501,
|
|
message: 'Invalid RET parameter value. Must be FULL or HDRS',
|
|
enhancedCode: null
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return null; // All validations passed
|
|
}
|
|
|
|
/**
|
|
* Applies validated MAIL FROM parameters to session envelope
|
|
* @param {Object} parsed - Parsed address command with args
|
|
*/
|
|
_applyMailParams(parsed) {
|
|
// Apply BODY parameter
|
|
if (parsed.args.BODY) {
|
|
this.session.envelope.bodyType = parsed.args.BODY.toLowerCase();
|
|
}
|
|
|
|
// Apply SMTPUTF8 parameter
|
|
if (parsed.args.SMTPUTF8 === true) {
|
|
this.session.envelope.smtpUtf8 = true;
|
|
}
|
|
|
|
// Apply REQUIRETLS parameter
|
|
if (parsed.args.REQUIRETLS === true) {
|
|
this.session.envelope.requireTLS = true;
|
|
}
|
|
|
|
// Apply DSN parameters
|
|
if (!this._server.options.hideDSN) {
|
|
if (parsed.args.RET) {
|
|
this.session.envelope.dsn.ret = parsed.args.RET.toUpperCase();
|
|
}
|
|
if (parsed.args.ENVID) {
|
|
this.session.envelope.dsn.envid = parsed.args.ENVID;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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', 'MAILBOX_SYNTAX_ERROR');
|
|
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, 'SYSTEM_FULL');
|
|
return callback();
|
|
}
|
|
|
|
// Validate all MAIL FROM parameters
|
|
const validationError = this._validateMailParams(parsed);
|
|
if (validationError) {
|
|
this.send(validationError.code, validationError.message, validationError.enhancedCode);
|
|
return callback();
|
|
}
|
|
|
|
// Apply validated parameters to session envelope before calling onMailFrom
|
|
// so the handler can access them
|
|
this._applyMailParams(parsed);
|
|
|
|
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', 'MAIL_FROM_OK');
|
|
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', 'MAILBOX_SYNTAX_ERROR');
|
|
return callback();
|
|
}
|
|
|
|
if (!this.session.envelope.mailFrom) {
|
|
this.send(503, 'Error: need MAIL command');
|
|
return callback();
|
|
}
|
|
|
|
// Process DSN parameters if DSN is supported
|
|
if (!this._server.options.hideDSN) {
|
|
// Validate NOTIFY parameter
|
|
if (parsed.args.NOTIFY) {
|
|
const notify = parsed.args.NOTIFY.toUpperCase();
|
|
const validNotifyValues = ['NEVER', 'SUCCESS', 'FAILURE', 'DELAY'];
|
|
const notifyValues = notify.split(',');
|
|
|
|
for (let value of notifyValues) {
|
|
if (!validNotifyValues.includes(value)) {
|
|
this.send(501, 'Error: NOTIFY parameter must be NEVER, SUCCESS, FAILURE, or DELAY');
|
|
return callback();
|
|
}
|
|
}
|
|
|
|
// NEVER cannot be combined with other values
|
|
if (notifyValues.includes('NEVER') && notifyValues.length > 1) {
|
|
this.send(501, 'Error: NOTIFY=NEVER cannot be combined with other values');
|
|
return callback();
|
|
}
|
|
|
|
parsed.dsn = parsed.dsn || {};
|
|
parsed.dsn.notify = notifyValues;
|
|
}
|
|
|
|
// Store ORCPT parameter
|
|
if (parsed.args.ORCPT) {
|
|
parsed.dsn = parsed.dsn || {};
|
|
parsed.dsn.orcpt = parsed.args.ORCPT;
|
|
}
|
|
}
|
|
|
|
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', 'RCPT_TO_OK');
|
|
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', 'DATA_OK');
|
|
}
|
|
});
|
|
} 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', 'DATA_OK');
|
|
}
|
|
} else {
|
|
// single success response when using SMTP
|
|
this.send(250, typeof message === 'string' ? message : 'OK: message queued', 'DATA_OK');
|
|
}
|
|
|
|
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 secureSocket;
|
|
|
|
let onError = err => {
|
|
const meta = {};
|
|
if (secureSocket) {
|
|
meta.tlsProtocol = secureSocket.getProtocol();
|
|
}
|
|
meta.protocol = 'smtp';
|
|
meta.stage = 'connect';
|
|
|
|
if (err) {
|
|
err.meta = meta;
|
|
}
|
|
|
|
if (err && /SSL[23]*_GET_CLIENT_HELLO|ssl[23]*_read_bytes|ssl_bytes_to_cipher_list/i.test(err.message)) {
|
|
let message = err.message;
|
|
err.message = 'Failed to establish TLS session';
|
|
err.responseCode = 500;
|
|
err.code = err.code || 'TLSError';
|
|
meta.message = message;
|
|
}
|
|
if (!err || !err.message) {
|
|
err = new Error('Socket closed while initiating TLS');
|
|
err.responseCode = 500;
|
|
err.code = 'SocketError';
|
|
err.report = false;
|
|
err.meta = meta;
|
|
}
|
|
|
|
this._onError(err);
|
|
};
|
|
|
|
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
|
|
secureSocket = new tls.TLSSocket(this._socket, socketOptions);
|
|
|
|
secureSocket.once('close', hadError => this._onCloseEvent(hadError));
|
|
secureSocket.once('error', err => onError(err));
|
|
secureSocket.once('_tlsError', err => onError(err));
|
|
secureSocket.once('clientError', err => 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 = this._socket.servername;
|
|
let cipher = this.session.tlsOptions && this.session.tlsOptions.name;
|
|
this._server.logger.info(
|
|
{
|
|
tnx: 'starttls',
|
|
cid: this.id,
|
|
user: (this.session.user && this.session.user.username) || this.session.user,
|
|
cipher
|
|
},
|
|
'Connection upgraded to TLS using',
|
|
cipher || 'N/A'
|
|
);
|
|
this._server.onSecure(this._socket, this.session, err => {
|
|
if (err) {
|
|
return this._onError(err);
|
|
}
|
|
this._socket.pipe(this._parser);
|
|
if (typeof secureCallback === 'function') {
|
|
secureCallback();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
// Expose to the world
|
|
module.exports.SMTPConnection = SMTPConnection;
|