2024-02-16 13:41:04 +01:00
|
|
|
import * as plugins from '../plugins.js';
|
|
|
|
import * as paths from '../paths.js';
|
2024-02-16 13:28:40 +01:00
|
|
|
import { Email } from './mta.classes.email.js';
|
2024-02-16 20:42:26 +01:00
|
|
|
import type { MtaService } from './mta.classes.mta.js';
|
2024-02-16 13:28:40 +01:00
|
|
|
|
|
|
|
export interface ISmtpServerOptions {
|
|
|
|
port: number;
|
|
|
|
key: string;
|
|
|
|
cert: string;
|
2025-03-15 13:45:29 +00:00
|
|
|
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;
|
2024-02-16 13:28:40 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
export class SMTPServer {
|
2024-02-16 20:42:26 +01:00
|
|
|
public mtaRef: MtaService;
|
2024-02-16 13:28:40 +01:00
|
|
|
private smtpServerOptions: ISmtpServerOptions;
|
|
|
|
private server: plugins.net.Server;
|
2025-03-15 13:45:29 +00:00
|
|
|
private sessions: Map<plugins.net.Socket | plugins.tls.TLSSocket, SmtpSession>;
|
|
|
|
private hostname: string;
|
2024-02-16 13:28:40 +01:00
|
|
|
|
2024-02-16 20:42:26 +01:00
|
|
|
constructor(mtaRefArg: MtaService, optionsArg: ISmtpServerOptions) {
|
2024-02-16 13:28:40 +01:00
|
|
|
console.log('SMTPServer instance is being created...');
|
|
|
|
|
|
|
|
this.mtaRef = mtaRefArg;
|
|
|
|
this.smtpServerOptions = optionsArg;
|
2025-03-15 13:45:29 +00:00
|
|
|
this.sessions = new Map();
|
|
|
|
this.hostname = optionsArg.hostname || 'mta.lossless.one';
|
2024-02-16 13:28:40 +01:00
|
|
|
|
|
|
|
this.server = plugins.net.createServer((socket) => {
|
2025-03-15 13:45:29 +00:00
|
|
|
this.handleNewConnection(socket);
|
2024-02-16 13:28:40 +01:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2025-03-15 13:45:29 +00:00
|
|
|
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
|
2024-02-16 13:28:40 +01:00
|
|
|
});
|
|
|
|
|
2025-03-15 13:45:29 +00:00
|
|
|
// Send greeting
|
|
|
|
this.sendResponse(socket, `220 ${this.hostname} ESMTP Service Ready`);
|
2024-02-16 13:28:40 +01:00
|
|
|
|
2025-03-15 13:45:29 +00:00
|
|
|
socket.on('data', (data) => {
|
|
|
|
this.processData(socket, data);
|
2024-02-16 13:28:40 +01:00
|
|
|
});
|
|
|
|
|
2025-03-15 13:45:29 +00:00
|
|
|
socket.on('end', () => {
|
|
|
|
console.log(`Connection ended from ${socket.remoteAddress}:${socket.remotePort}`);
|
|
|
|
const session = this.sessions.get(socket);
|
|
|
|
if (session) {
|
|
|
|
session.connectionEnded = true;
|
|
|
|
}
|
2024-02-16 13:28:40 +01:00
|
|
|
});
|
|
|
|
|
2025-03-15 13:45:29 +00:00
|
|
|
socket.on('error', (err) => {
|
|
|
|
console.error(`Socket error: ${err.message}`);
|
|
|
|
this.sessions.delete(socket);
|
|
|
|
socket.destroy();
|
2024-02-16 13:28:40 +01:00
|
|
|
});
|
|
|
|
|
2025-03-15 13:45:29 +00:00
|
|
|
socket.on('close', () => {
|
|
|
|
console.log(`Connection closed from ${socket.remoteAddress}:${socket.remotePort}`);
|
|
|
|
this.sessions.delete(socket);
|
2024-02-16 13:28:40 +01:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2025-03-15 13:45:29 +00:00
|
|
|
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}`);
|
2024-02-16 13:28:40 +01:00
|
|
|
socket.destroy();
|
|
|
|
}
|
2025-03-15 13:45:29 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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');
|
2024-02-16 13:28:40 +01:00
|
|
|
|
2025-03-15 13:45:29 +00:00
|
|
|
// 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;
|
2024-02-16 13:28:40 +01:00
|
|
|
}
|
2025-03-15 13:45:29 +00:00
|
|
|
|
|
|
|
this.sendResponse(socket, '220 Ready to start TLS');
|
|
|
|
this.startTLS(socket);
|
2024-02-16 13:28:40 +01:00
|
|
|
}
|
|
|
|
|
2025-03-15 13:45:29 +00:00
|
|
|
private handleMailFrom(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void {
|
|
|
|
const session = this.sessions.get(socket);
|
|
|
|
if (!session) return;
|
2024-02-16 13:28:40 +01:00
|
|
|
|
2025-03-15 13:45:29 +00:00
|
|
|
if (session.state !== SmtpState.AFTER_EHLO) {
|
|
|
|
this.sendResponse(socket, '503 Bad sequence of commands');
|
|
|
|
return;
|
|
|
|
}
|
2024-02-16 13:28:40 +01:00
|
|
|
|
2025-03-15 13:45:29 +00:00
|
|
|
// Extract email from MAIL FROM:<user@example.com>
|
|
|
|
const emailMatch = args.match(/FROM:<([^>]*)>/i);
|
|
|
|
if (!emailMatch) {
|
|
|
|
this.sendResponse(socket, '501 Syntax error in parameters or arguments');
|
|
|
|
return;
|
|
|
|
}
|
2024-02-16 13:28:40 +01:00
|
|
|
|
2025-03-15 13:45:29 +00:00
|
|
|
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:<user@example.com>
|
|
|
|
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 <CR><LF>.<CR><LF>');
|
|
|
|
}
|
|
|
|
|
|
|
|
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<void> {
|
|
|
|
const session = this.sessions.get(socket);
|
|
|
|
if (!session || !session.emailData) {
|
|
|
|
console.error('No email data found for session.');
|
2024-02-16 13:28:40 +01:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
let mightBeSpam = false;
|
|
|
|
|
|
|
|
// Verifying the email with DKIM
|
|
|
|
try {
|
2025-03-15 13:45:29 +00:00
|
|
|
const isVerified = await this.mtaRef.dkimVerifier.verify(session.emailData);
|
2024-02-16 13:28:40 +01:00
|
|
|
mightBeSpam = !isVerified;
|
|
|
|
} catch (error) {
|
|
|
|
console.error('Failed to verify DKIM signature:', error);
|
|
|
|
mightBeSpam = true;
|
|
|
|
}
|
|
|
|
|
2025-03-15 13:45:29 +00:00
|
|
|
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) => ({
|
2024-02-16 13:28:40 +01:00
|
|
|
filename: attachment.filename || '',
|
|
|
|
content: attachment.content,
|
|
|
|
contentType: attachment.contentType,
|
|
|
|
})) || [],
|
2025-03-15 13:45:29 +00:00
|
|
|
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;
|
|
|
|
}
|
2024-02-16 13:28:40 +01:00
|
|
|
|
2025-03-15 13:45:29 +00:00
|
|
|
// 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();
|
|
|
|
});
|
2024-02-16 13:28:40 +01:00
|
|
|
|
2025-03-15 13:45:29 +00:00
|
|
|
tlsSocket.on('close', () => {
|
|
|
|
console.log('TLS socket closed');
|
|
|
|
this.sessions.delete(tlsSocket);
|
|
|
|
});
|
|
|
|
} catch (error) {
|
|
|
|
console.error('Error upgrading connection to TLS:', error);
|
|
|
|
socket.destroy();
|
|
|
|
}
|
2024-02-16 13:28:40 +01:00
|
|
|
}
|
|
|
|
|
2025-03-15 13:45:29 +00:00
|
|
|
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 {
|
2024-02-16 13:28:40 +01:00
|
|
|
this.server.listen(this.smtpServerOptions.port, () => {
|
|
|
|
console.log(`SMTP Server is now running on port ${this.smtpServerOptions.port}`);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2025-03-15 13:45:29 +00:00
|
|
|
public stop(): void {
|
2024-02-16 13:28:40 +01:00
|
|
|
this.server.getConnections((err, count) => {
|
|
|
|
if (err) throw err;
|
|
|
|
console.log('Number of active connections: ', count);
|
|
|
|
});
|
2025-03-15 13:45:29 +00:00
|
|
|
|
2024-02-16 13:28:40 +01:00
|
|
|
this.server.close(() => {
|
|
|
|
console.log('SMTP Server is now stopped');
|
|
|
|
});
|
|
|
|
}
|
2025-03-15 13:45:29 +00:00
|
|
|
}
|