import * as plugins from '../plugins.js'; import * as paths from '../paths.js'; import { Email } from './mta.classes.email.js'; import type { MtaService } from './mta.classes.mta.js'; export interface ISmtpServerOptions { port: number; key: string; cert: string; hostname?: string; } // SMTP Session States enum SmtpState { GREETING, AFTER_EHLO, MAIL_FROM, RCPT_TO, DATA, DATA_RECEIVING, FINISHED } // Structure to store session information interface SmtpSession { state: SmtpState; clientHostname: string; mailFrom: string; rcptTo: string[]; emailData: string; useTLS: boolean; connectionEnded: boolean; } export class SMTPServer { public mtaRef: MtaService; private smtpServerOptions: ISmtpServerOptions; private server: plugins.net.Server; private sessions: Map; private hostname: string; constructor(mtaRefArg: MtaService, optionsArg: ISmtpServerOptions) { console.log('SMTPServer instance is being created...'); this.mtaRef = mtaRefArg; this.smtpServerOptions = optionsArg; this.sessions = new Map(); this.hostname = optionsArg.hostname || 'mta.lossless.one'; this.server = plugins.net.createServer((socket) => { this.handleNewConnection(socket); }); } private handleNewConnection(socket: plugins.net.Socket): void { console.log(`New connection from ${socket.remoteAddress}:${socket.remotePort}`); // Initialize a new session this.sessions.set(socket, { state: SmtpState.GREETING, clientHostname: '', mailFrom: '', rcptTo: [], emailData: '', useTLS: false, connectionEnded: false }); // Send greeting this.sendResponse(socket, `220 ${this.hostname} ESMTP Service Ready`); socket.on('data', (data) => { this.processData(socket, data); }); socket.on('end', () => { console.log(`Connection ended from ${socket.remoteAddress}:${socket.remotePort}`); const session = this.sessions.get(socket); if (session) { session.connectionEnded = true; } }); socket.on('error', (err) => { console.error(`Socket error: ${err.message}`); this.sessions.delete(socket); socket.destroy(); }); socket.on('close', () => { console.log(`Connection closed from ${socket.remoteAddress}:${socket.remotePort}`); this.sessions.delete(socket); }); } private sendResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, response: string): void { try { socket.write(`${response}\r\n`); console.log(`→ ${response}`); } catch (error) { console.error(`Error sending response: ${error.message}`); socket.destroy(); } } private processData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: Buffer): void { const session = this.sessions.get(socket); if (!session) { console.error('No session found for socket. Closing connection.'); socket.destroy(); return; } // If we're in DATA_RECEIVING state, handle differently if (session.state === SmtpState.DATA_RECEIVING) { return this.processEmailData(socket, data.toString()); } // Process normal SMTP commands const lines = data.toString().split('\r\n').filter(line => line.length > 0); for (const line of lines) { console.log(`← ${line}`); this.processCommand(socket, line); } } private processCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, commandLine: string): void { const session = this.sessions.get(socket); if (!session || session.connectionEnded) return; const [command, ...args] = commandLine.split(' '); const upperCommand = command.toUpperCase(); switch (upperCommand) { case 'EHLO': case 'HELO': this.handleEhlo(socket, args.join(' ')); break; case 'STARTTLS': this.handleStartTls(socket); break; case 'MAIL': this.handleMailFrom(socket, args.join(' ')); break; case 'RCPT': this.handleRcptTo(socket, args.join(' ')); break; case 'DATA': this.handleData(socket); break; case 'RSET': this.handleRset(socket); break; case 'QUIT': this.handleQuit(socket); break; case 'NOOP': this.sendResponse(socket, '250 OK'); break; default: this.sendResponse(socket, '502 Command not implemented'); } } private handleEhlo(socket: plugins.net.Socket | plugins.tls.TLSSocket, clientHostname: string): void { const session = this.sessions.get(socket); if (!session) return; if (!clientHostname) { this.sendResponse(socket, '501 Syntax error in parameters or arguments'); return; } session.clientHostname = clientHostname; session.state = SmtpState.AFTER_EHLO; // List available extensions this.sendResponse(socket, `250-${this.hostname} Hello ${clientHostname}`); this.sendResponse(socket, '250-SIZE 10485760'); // 10MB max this.sendResponse(socket, '250-8BITMIME'); // Only offer STARTTLS if we haven't already established it if (!session.useTLS) { this.sendResponse(socket, '250-STARTTLS'); } this.sendResponse(socket, '250 HELP'); } private handleStartTls(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { const session = this.sessions.get(socket); if (!session) return; if (session.state !== SmtpState.AFTER_EHLO) { this.sendResponse(socket, '503 Bad sequence of commands'); return; } if (session.useTLS) { this.sendResponse(socket, '503 TLS already active'); return; } this.sendResponse(socket, '220 Ready to start TLS'); this.startTLS(socket); } private handleMailFrom(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void { const session = this.sessions.get(socket); if (!session) return; if (session.state !== SmtpState.AFTER_EHLO) { this.sendResponse(socket, '503 Bad sequence of commands'); return; } // Extract email from MAIL FROM: const emailMatch = args.match(/FROM:<([^>]*)>/i); if (!emailMatch) { this.sendResponse(socket, '501 Syntax error in parameters or arguments'); return; } const email = emailMatch[1]; if (!this.isValidEmail(email)) { this.sendResponse(socket, '501 Invalid email address'); return; } session.mailFrom = email; session.state = SmtpState.MAIL_FROM; this.sendResponse(socket, '250 OK'); } private handleRcptTo(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void { const session = this.sessions.get(socket); if (!session) return; if (session.state !== SmtpState.MAIL_FROM && session.state !== SmtpState.RCPT_TO) { this.sendResponse(socket, '503 Bad sequence of commands'); return; } // Extract email from RCPT TO: const emailMatch = args.match(/TO:<([^>]*)>/i); if (!emailMatch) { this.sendResponse(socket, '501 Syntax error in parameters or arguments'); return; } const email = emailMatch[1]; if (!this.isValidEmail(email)) { this.sendResponse(socket, '501 Invalid email address'); return; } session.rcptTo.push(email); session.state = SmtpState.RCPT_TO; this.sendResponse(socket, '250 OK'); } private handleData(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { const session = this.sessions.get(socket); if (!session) return; if (session.state !== SmtpState.RCPT_TO) { this.sendResponse(socket, '503 Bad sequence of commands'); return; } session.state = SmtpState.DATA_RECEIVING; session.emailData = ''; this.sendResponse(socket, '354 End data with .'); } private handleRset(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { const session = this.sessions.get(socket); if (!session) return; // Reset the session data but keep connection information session.state = SmtpState.AFTER_EHLO; session.mailFrom = ''; session.rcptTo = []; session.emailData = ''; this.sendResponse(socket, '250 OK'); } private handleQuit(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { const session = this.sessions.get(socket); if (!session) return; this.sendResponse(socket, '221 Goodbye'); // If we have collected email data, try to parse it before closing if (session.state === SmtpState.FINISHED && session.emailData.length > 0) { this.parseEmail(socket); } socket.end(); this.sessions.delete(socket); } private processEmailData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): void { const session = this.sessions.get(socket); if (!session) return; // Check for end of data marker if (data.endsWith('\r\n.\r\n')) { // Remove the end of data marker const emailData = data.slice(0, -5); session.emailData += emailData; session.state = SmtpState.FINISHED; // Save and process the email this.saveEmail(socket); this.sendResponse(socket, '250 OK: Message accepted for delivery'); } else { // Accumulate the data session.emailData += data; } } private saveEmail(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { const session = this.sessions.get(socket); if (!session) return; try { // Ensure the directory exists plugins.smartfile.fs.ensureDirSync(paths.receivedEmailsDir); // Write the email to disk plugins.smartfile.memory.toFsSync( session.emailData, plugins.path.join(paths.receivedEmailsDir, `${Date.now()}.eml`) ); // Parse the email this.parseEmail(socket); } catch (error) { console.error('Error saving email:', error); } } private async parseEmail(socket: plugins.net.Socket | plugins.tls.TLSSocket): Promise { const session = this.sessions.get(socket); if (!session || !session.emailData) { console.error('No email data found for session.'); return; } let mightBeSpam = false; // Verifying the email with DKIM try { const isVerified = await this.mtaRef.dkimVerifier.verify(session.emailData); mightBeSpam = !isVerified; } catch (error) { console.error('Failed to verify DKIM signature:', error); mightBeSpam = true; } try { const parsedEmail = await plugins.mailparser.simpleParser(session.emailData); const email = new Email({ from: parsedEmail.from?.value[0].address || session.mailFrom, to: session.rcptTo[0], // Use the first recipient subject: parsedEmail.subject || '', text: parsedEmail.html || parsedEmail.text || '', attachments: parsedEmail.attachments?.map((attachment) => ({ filename: attachment.filename || '', content: attachment.content, contentType: attachment.contentType, })) || [], mightBeSpam: mightBeSpam, }); console.log('Email received and parsed:', { from: email.from, to: email.to, subject: email.subject, attachments: email.attachments.length, mightBeSpam: email.mightBeSpam }); // Process or forward the email as needed // this.mtaRef.processIncomingEmail(email); // You could add this method to your MTA service } catch (error) { console.error('Error parsing email:', error); } } private startTLS(socket: plugins.net.Socket): void { try { const secureContext = plugins.tls.createSecureContext({ key: this.smtpServerOptions.key, cert: this.smtpServerOptions.cert, }); const tlsSocket = new plugins.tls.TLSSocket(socket, { secureContext: secureContext, isServer: true, server: this.server }); const originalSession = this.sessions.get(socket); if (!originalSession) { console.error('No session found when upgrading to TLS'); return; } // Transfer the session data to the new TLS socket this.sessions.set(tlsSocket, { ...originalSession, useTLS: true, state: SmtpState.GREETING // Reset state to require a new EHLO }); this.sessions.delete(socket); tlsSocket.on('secure', () => { console.log('TLS negotiation successful'); }); tlsSocket.on('data', (data: Buffer) => { this.processData(tlsSocket, data); }); tlsSocket.on('end', () => { console.log('TLS socket ended'); const session = this.sessions.get(tlsSocket); if (session) { session.connectionEnded = true; } }); tlsSocket.on('error', (err) => { console.error('TLS socket error:', err); this.sessions.delete(tlsSocket); tlsSocket.destroy(); }); tlsSocket.on('close', () => { console.log('TLS socket closed'); this.sessions.delete(tlsSocket); }); } catch (error) { console.error('Error upgrading connection to TLS:', error); socket.destroy(); } } private isValidEmail(email: string): boolean { // Basic email validation - more comprehensive validation could be implemented const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email); } public start(): void { this.server.listen(this.smtpServerOptions.port, () => { console.log(`SMTP Server is now running on port ${this.smtpServerOptions.port}`); }); } public stop(): void { this.server.getConnections((err, count) => { if (err) throw err; console.log('Number of active connections: ', count); }); this.server.close(() => { console.log('SMTP Server is now stopped'); }); } }