UNPKG

smtp-server

Version:

Create custom SMTP servers on the fly

1,408 lines (1,203 loc) 66.6 kB
'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