import net from 'node:net'; import tls from 'node:tls'; import { readFileSync } from 'node:fs'; import { getDomainByName, logSendEvent, verifySmtpCredential } from './db.js'; import { domainFromAddress, extractAddress, sendViaSmtp, signMessageForDomain } from './mailer.js'; const implicitTlsDetectTimeoutMs = 300; export function startSubmissionServer(config) { if (!config.enabled) return null; const tlsMaterial = loadTlsMaterial(config); const servers = []; for (const listener of config.listeners) { const listenerConfig = { ...config, port: listener.port, protocol: listener.protocol, secureContext: tlsMaterial?.secureContext || null, tlsActive: listener.protocol === 'smtps', startTlsAvailable: listener.protocol === 'smtp' && Boolean(tlsMaterial?.secureContext) }; const server = listener.protocol === 'smtps' ? tls.createServer({ key: tlsMaterial?.key, cert: tlsMaterial?.cert }, (socket) => new SubmissionSession(socket, listenerConfig)) : net.createServer((socket) => acceptSubmissionSocket(socket, listenerConfig)); server.listen(listener.port, '0.0.0.0', () => { console.log(`MailHub SMTP ${listener.protocol} listening on 0.0.0.0:${listener.port}`); }); servers.push(server); } return servers; } function loadTlsMaterial(config) { if (!config.tlsKeyPath || !config.tlsCertPath) { console.warn('SMTP TLS certificate paths are not configured; STARTTLS/SMTPS will be unavailable.'); return null; } try { const key = readFileSync(config.tlsKeyPath); const cert = readFileSync(config.tlsCertPath); return tls.createSecureContext({ key, cert }) && { key, cert, secureContext: tls.createSecureContext({ key, cert }) }; } catch (error) { console.warn(`Unable to load SMTP TLS certificate: ${error.message}`); return null; } } function acceptSubmissionSocket(socket, listenerConfig) { if (listenerConfig.protocol !== 'smtp' || !listenerConfig.secureContext) { new SubmissionSession(socket, listenerConfig); return; } let settled = false; const timer = setTimeout(() => startPlainSession(), implicitTlsDetectTimeoutMs); function cleanup() { clearTimeout(timer); socket.off('data', onData); socket.off('error', onError); } function onError() { cleanup(); } function onData(chunk) { socket.pause(); if (isTlsClientHello(chunk)) { startImplicitTlsSession(chunk); } else { startPlainSession(chunk); } } function startPlainSession(firstChunk) { if (settled) return; settled = true; cleanup(); const session = new SubmissionSession(socket, listenerConfig); if (firstChunk?.length) session.onData(firstChunk); socket.resume(); } function startImplicitTlsSession(firstChunk) { if (settled) return; settled = true; cleanup(); socket.unshift(firstChunk); const secureSocket = new tls.TLSSocket(socket, { isServer: true, secureContext: listenerConfig.secureContext }); const secureConfig = { ...listenerConfig, tlsActive: true, startTlsAvailable: false }; let sessionStarted = false; const startSession = () => { if (sessionStarted) return; sessionStarted = true; secureSocket.off('secure', startSession); secureSocket.off('secureConnect', startSession); new SubmissionSession(secureSocket, secureConfig); secureSocket.resume(); }; secureSocket.once('secure', startSession); secureSocket.once('secureConnect', startSession); secureSocket.on('error', () => null); secureSocket.resume(); } socket.once('data', onData); socket.once('error', onError); } function isTlsClientHello(chunk) { return chunk?.length >= 3 && chunk[0] === 0x16 && chunk[1] === 0x03; } export function parseSubmissionListeners(value) { return String(value || '25:smtp,587:smtp,465:smtps,2525:smtp') .split(',') .map((item) => item.trim()) .filter(Boolean) .map((item) => { const [portRaw, protocolRaw = 'smtp'] = item.split(':'); const port = Number(portRaw); const protocol = protocolRaw.toLowerCase() === 'smtps' ? 'smtps' : 'smtp'; if (!Number.isInteger(port) || port <= 0 || port > 65535) return null; return { port, protocol }; }) .filter(Boolean); } export function publicSubmissionListeners(listeners) { return listeners.map((listener) => ({ port: listener.port, protocol: listener.protocol === 'smtps' ? 'SMTPS' : 'SMTP + STARTTLS' })); } class SubmissionSession { constructor(socket, config) { this.socket = socket; this.config = config; this.buffer = ''; this.dataMode = false; this.dataLines = []; this.authState = ''; this.authUser = ''; this.user = null; this.authenticated = false; this.mailFrom = ''; this.recipients = []; this.remoteAddress = socket.remoteAddress || ''; this.onDataBound = (chunk) => this.onData(chunk); this.queue = Promise.resolve(); socket.setEncoding('utf8'); socket.on('data', this.onDataBound); socket.on('error', () => null); this.write(220, `${config.hostname} MailHub SMTP ready`); } onData(chunk) { this.buffer += chunk; let index; while ((index = this.buffer.indexOf('\n')) !== -1) { const line = this.buffer.slice(0, index).replace(/\r$/, ''); this.buffer = this.buffer.slice(index + 1); this.queue = this.queue .then(() => this.onLine(line)) .catch((error) => { console.error(error); this.write(451, 'Temporary local error'); }); } } async onLine(line) { if (this.dataMode) { if (line === '.') return await this.finishData(); this.dataLines.push(line.startsWith('..') ? line.slice(1) : line); return; } if (this.authState) return this.continueAuth(line); const [rawCommand, ...args] = line.split(' '); const command = rawCommand.toUpperCase(); const argument = args.join(' ').trim(); if (command === 'EHLO' || command === 'HELO') return this.ehlo(); if (command === 'NOOP') return this.write(250, 'OK'); if (command === 'RSET') return this.resetEnvelope(); if (command === 'QUIT') { this.write(221, 'Bye'); return this.socket.end(); } if (command === 'AUTH') return this.auth(argument); if (command === 'STARTTLS') return this.startTls(); if (command === 'MAIL') return this.mail(argument); if (command === 'RCPT') return this.rcpt(argument); if (command === 'DATA') return this.data(); return this.write(502, 'Command not implemented'); } ehlo() { this.socket.write(`250-${this.config.hostname}\r\n`); this.socket.write('250-SIZE 52428800\r\n'); this.socket.write('250-8BITMIME\r\n'); if (this.config.startTlsAvailable && !this.config.tlsActive) { this.socket.write('250-STARTTLS\r\n'); } if (this.canAuthenticate()) { this.socket.write('250-AUTH PLAIN LOGIN\r\n'); } this.socket.write('250 SMTPUTF8\r\n'); } startTls() { if (!this.config.startTlsAvailable || !this.config.secureContext) return this.write(454, 'TLS is not available'); if (this.config.tlsActive) return this.write(503, 'TLS is already active'); this.write(220, 'Ready to start TLS'); this.socket.removeListener('data', this.onDataBound); const secureSocket = new tls.TLSSocket(this.socket, { isServer: true, secureContext: this.config.secureContext }); this.socket = secureSocket; this.buffer = ''; this.authenticated = false; this.user = null; this.authState = ''; this.config = { ...this.config, tlsActive: true, startTlsAvailable: false }; secureSocket.setEncoding('utf8'); secureSocket.on('data', this.onDataBound); secureSocket.on('error', () => null); } auth(argument) { if (!this.canAuthenticate()) return this.write(538, 'Encryption required for authentication'); const [methodRaw, response] = argument.split(/\s+/, 2); const method = String(methodRaw || '').toUpperCase(); if (method === 'PLAIN') { if (!response) { this.authState = 'plain'; return this.write(334, ''); } return this.finishPlainAuth(response); } if (method === 'LOGIN') { this.authState = 'login-username'; return this.write(334, Buffer.from('Username:').toString('base64')); } return this.write(504, 'Unsupported authentication method'); } continueAuth(line) { if (this.authState === 'plain') return this.finishPlainAuth(line); if (this.authState === 'login-username') { this.authUser = decodeBase64(line); this.authState = 'login-password'; return this.write(334, Buffer.from('Password:').toString('base64')); } if (this.authState === 'login-password') { const password = decodeBase64(line); this.authState = ''; return this.finishAuth(this.authUser, password); } } finishPlainAuth(response) { const decoded = decodeBase64(response); const parts = decoded.split('\u0000'); const user = parts[1] || parts[0] || ''; const password = parts[2] || parts[1] || ''; this.authState = ''; return this.finishAuth(user, password); } finishAuth(user, password) { const auth = verifySmtpCredential(user, password); if (auth?.user) { this.user = auth.user; this.authenticated = true; return this.write(235, 'Authentication successful'); } this.user = null; this.authenticated = false; return this.write(535, 'Authentication failed'); } mail(argument) { if (!this.authenticated) return this.write(530, 'Authentication required'); const address = extractPathAddress(argument); if (!address) return this.write(501, 'Invalid MAIL FROM'); this.mailFrom = address; this.recipients = []; return this.write(250, 'Sender OK'); } rcpt(argument) { if (!this.authenticated) return this.write(530, 'Authentication required'); if (!this.mailFrom) return this.write(503, 'MAIL FROM required first'); const address = extractPathAddress(argument); if (!address) return this.write(501, 'Invalid RCPT TO'); if (this.recipients.length >= 100) return this.write(452, 'Too many recipients'); this.recipients.push(address); return this.write(250, 'Recipient OK'); } data() { if (!this.authenticated) return this.write(530, 'Authentication required'); if (!this.mailFrom || !this.recipients.length) return this.write(503, 'Need MAIL FROM and RCPT TO first'); this.dataMode = true; this.dataLines = []; return this.write(354, 'End data with .'); } async finishData() { this.dataMode = false; const rawMessage = `${this.dataLines.join('\r\n')}\r\n`; const headerFrom = extractHeader(rawMessage, 'from'); const subject = decodeHeader(extractHeader(rawMessage, 'subject')) || '(no subject)'; const sender = extractAddress(headerFrom) || this.mailFrom; const domainName = domainFromAddress(sender || this.mailFrom); const domain = getDomainByName(domainName, { userId: this.user?.id, includePrivate: true }); if (!domain) { logSendEvent({ userId: this.user?.id || null, domainId: null, sender: sender || this.mailFrom, recipients: this.recipients, subject, status: 'failed', detail: `Sender domain ${domainName || '(unknown)'} is not configured` }); return this.write(550, 'Sender domain is not configured in MailHub'); } try { const signed = signMessageForDomain(rawMessage, domain); const smtpResult = await sendViaSmtp({ host: this.config.relayHost, port: this.config.relayPort, secure: this.config.relaySecure, username: this.config.relayUsername, password: this.config.relayPassword, helo: this.config.relayHelo, mailFrom: this.mailFrom, recipients: this.recipients, rawMessage: signed }); logSendEvent({ userId: this.user.id, domainId: domain.id, sender: sender || this.mailFrom, recipients: this.recipients, subject, status: 'queued', detail: `submission ${this.remoteAddress}; ${smtpResult.message}`, queueId: smtpResult.queueId, deliveryLog: smtpResult.deliveryLog }); this.resetEnvelope(false); return this.write(250, 'Message queued'); } catch (error) { logSendEvent({ userId: this.user.id, domainId: domain.id, sender: sender || this.mailFrom, recipients: this.recipients, subject, status: 'failed', detail: `submission ${this.remoteAddress}; ${error.message}`, deliveryLog: deliveryLogFromError(error) }); return this.write(451, 'Temporary local delivery error'); } } resetEnvelope(reply = true) { this.mailFrom = ''; this.recipients = []; this.dataMode = false; this.dataLines = []; this.user = this.authenticated ? this.user : null; if (reply) this.write(250, 'OK'); } write(code, message) { this.socket.write(`${code} ${message}\r\n`); } canAuthenticate() { return this.config.tlsActive || this.config.allowInsecureAuth; } } function extractPathAddress(argument) { const match = String(argument || '').match(/FROM:\s*<([^>]+)>|TO:\s*<([^>]+)>/i); const raw = match ? (match[1] || match[2]) : argument; return extractAddress(raw); } function extractHeader(rawMessage, name) { const head = rawMessage.split(/\r?\n\r?\n/, 1)[0] || ''; const lines = head.split(/\r?\n/); const headers = []; for (const line of lines) { if (/^[\t ]/.test(line) && headers.length) { headers[headers.length - 1].value += ` ${line.trim()}`; continue; } const index = line.indexOf(':'); if (index === -1) continue; headers.push({ name: line.slice(0, index).toLowerCase(), value: line.slice(index + 1).trim() }); } return headers.reverse().find((header) => header.name === name.toLowerCase())?.value || ''; } function decodeHeader(value) { return String(value || '').replace(/=\?UTF-8\?B\?([^?]+)\?=/gi, (_, encoded) => { try { return Buffer.from(encoded, 'base64').toString('utf8'); } catch { return _; } }); } function decodeBase64(value) { try { return Buffer.from(String(value || ''), 'base64').toString('utf8'); } catch { return ''; } } function deliveryLogFromError(error) { if (Array.isArray(error?.deliveryLog)) return error.deliveryLog; return [{ at: new Date().toISOString(), phase: 'error', direction: 'system', message: error?.message || 'Unknown SMTP delivery error', ok: false }]; }