'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 . // 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 .'); 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;