smtp-server
Version:
Create custom SMTP servers on the fly
1,408 lines (1,203 loc) • 66.6 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;
// 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 || 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', 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._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 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 length limits (prevents ReDoS on regex)
if (localPart.length > 64 || domain.length > 255) {
invalid = true;
} else {
// Validate local-part format
// Allowed characters: alphanumeric (including Unicode), and !#$%&'*+/=?^_`{|}~-
// Dots allowed but not consecutive, at start, or at end
if (
!/^[a-zA-Z0-9\u0080-\uFFFF!#$%&'*+/=?^_`{|}~-]+(\.[a-zA-Z0-9\u0080-\uFFFF!#$%&'*+/=?^_`{|}~-]+)*$/.test(localPart) ||
localPart.startsWith('.') ||
localPart.endsWith('.') ||
localPart.includes('..')
) {
invalid = true;
}
// Validate domain format (before punycode conversion)
// Domain labels: alphanumeric and hyphens, not starting/ending with hyphen
// Dots separate labels, no consecutive dots, no leading/trailing dots
if (
!invalid &&
(!/^[a-zA-Z0-9\u0080-\uFFFF]([a-zA-Z0-9\u0080-\uFFFF-]{0,61}[a-zA-Z0-9\u0080-\uFFFF])?(\.[a-zA-Z0-9\u0080-\uFFFF]([a-zA-Z0-9\u0080-\uFFFF-]{0,61}[a-zA-Z0-9\u0080-\uFFFF])?)*$/.test(
domain
) ||
domain.startsWith('.') ||
domain.endsWith('.') ||
domain.includes('..') ||
domain.includes('.-') ||
domain.includes('-.'))
) {
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_' + meth