| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469 |
- 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 <CR><LF>.<CR><LF>');
- }
- 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
- }];
- }
|