423 lines
12 KiB
TypeScript
423 lines
12 KiB
TypeScript
import * as plugins from '../plugins.js';
|
|
import { Readable } from 'node:stream';
|
|
import type { ISmtpConfig, ISmtpAuthConfig } from './classes.smtp.config.js';
|
|
import { EventEmitter } from 'node:events';
|
|
|
|
/**
|
|
* Connection session information
|
|
*/
|
|
export interface ISmtpSession {
|
|
id: string;
|
|
remoteAddress: string;
|
|
remotePort: number;
|
|
clientHostname?: string;
|
|
secure: boolean;
|
|
transmissionType?: 'SMTP' | 'ESMTP';
|
|
user?: {
|
|
username: string;
|
|
[key: string]: any;
|
|
};
|
|
envelope?: {
|
|
mailFrom: {
|
|
address: string;
|
|
args: any;
|
|
};
|
|
rcptTo: Array<{
|
|
address: string;
|
|
args: any;
|
|
}>;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Authentication data
|
|
*/
|
|
export interface IAuthData {
|
|
method: string;
|
|
username: string;
|
|
password: string;
|
|
}
|
|
|
|
/**
|
|
* SMTP Server class for receiving emails
|
|
*/
|
|
export class SmtpServer extends EventEmitter {
|
|
private config: ISmtpConfig;
|
|
private server: any; // Will be SMTPServer from smtp-server once we add the dependency
|
|
private incomingConnections: Map<string, ISmtpSession> = new Map();
|
|
|
|
/**
|
|
* Create a new SMTP server
|
|
* @param config SMTP server configuration
|
|
*/
|
|
constructor(config: ISmtpConfig) {
|
|
super();
|
|
this.config = config;
|
|
}
|
|
|
|
/**
|
|
* Initialize and start the SMTP server
|
|
*/
|
|
public async start(): Promise<void> {
|
|
try {
|
|
// This is a placeholder for the actual server creation
|
|
// In the real implementation, we would use the smtp-server package
|
|
console.log(`Starting SMTP server on ports ${this.config.ports.join(', ')}`);
|
|
|
|
// Setup TLS options if provided
|
|
const tlsOptions = this.config.tls ? {
|
|
key: this.config.tls.keyPath ? await plugins.fs.promises.readFile(this.config.tls.keyPath, 'utf8') : undefined,
|
|
cert: this.config.tls.certPath ? await plugins.fs.promises.readFile(this.config.tls.certPath, 'utf8') : undefined,
|
|
ca: this.config.tls.caPath ? await plugins.fs.promises.readFile(this.config.tls.caPath, 'utf8') : undefined,
|
|
minVersion: this.config.tls.minVersion || 'TLSv1.2',
|
|
ciphers: this.config.tls.ciphers
|
|
} : undefined;
|
|
|
|
// Create the server
|
|
// Note: In the actual implementation, this would use SMTPServer from smtp-server
|
|
this.server = {
|
|
// Placeholder for server instance
|
|
async close() {
|
|
console.log('SMTP server closed');
|
|
}
|
|
};
|
|
|
|
// Set up event handlers
|
|
this.setupEventHandlers();
|
|
|
|
// Listen on all specified ports
|
|
for (const port of this.config.ports) {
|
|
// In actual implementation, this would call server.listen(port)
|
|
console.log(`SMTP server listening on port ${port}`);
|
|
}
|
|
|
|
this.emit('started');
|
|
} catch (error) {
|
|
console.error('Failed to start SMTP server:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop the SMTP server
|
|
*/
|
|
public async stop(): Promise<void> {
|
|
try {
|
|
if (this.server) {
|
|
// Close the server
|
|
await this.server.close();
|
|
this.server = null;
|
|
|
|
// Clear connection tracking
|
|
this.incomingConnections.clear();
|
|
|
|
this.emit('stopped');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error stopping SMTP server:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set up event handlers for the SMTP server
|
|
*/
|
|
private setupEventHandlers(): void {
|
|
// These would be connected to actual server events in implementation
|
|
|
|
// Connection handler
|
|
this.onConnect((session, callback) => {
|
|
// Store connection in tracking map
|
|
this.incomingConnections.set(session.id, session);
|
|
|
|
// Check if connection is allowed based on IP
|
|
if (!this.isIpAllowed(session.remoteAddress)) {
|
|
return callback(new Error('Connection refused'));
|
|
}
|
|
|
|
// Accept the connection
|
|
callback();
|
|
});
|
|
|
|
// Authentication handler
|
|
this.onAuth((auth, session, callback) => {
|
|
// Skip auth check if not required
|
|
if (!this.config.auth?.required) {
|
|
return callback(null, { user: auth.username });
|
|
}
|
|
|
|
// Check authentication
|
|
if (this.authenticateUser(auth)) {
|
|
return callback(null, { user: auth.username });
|
|
}
|
|
|
|
// Authentication failed
|
|
callback(new Error('Invalid credentials'));
|
|
});
|
|
|
|
// Sender validation
|
|
this.onMailFrom((address, session, callback) => {
|
|
// Validate sender address if needed
|
|
// Accept the sender
|
|
callback();
|
|
});
|
|
|
|
// Recipient validation
|
|
this.onRcptTo((address, session, callback) => {
|
|
// Validate recipient address
|
|
// Check if we handle this domain
|
|
if (!this.isDomainHandled(address.address.split('@')[1])) {
|
|
return callback(new Error('Domain not handled by this server'));
|
|
}
|
|
|
|
// Accept the recipient
|
|
callback();
|
|
});
|
|
|
|
// Message data handler
|
|
this.onData((stream, session, callback) => {
|
|
// Process the incoming message
|
|
this.processMessageData(stream, session)
|
|
.then(() => callback())
|
|
.catch(err => callback(err));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Process incoming message data
|
|
* @param stream Message data stream
|
|
* @param session SMTP session
|
|
*/
|
|
private async processMessageData(stream: Readable, session: ISmtpSession): Promise<void> {
|
|
return new Promise<void>((resolve, reject) => {
|
|
// Collect the message data
|
|
let messageData = '';
|
|
let messageSize = 0;
|
|
|
|
stream.on('data', (chunk) => {
|
|
messageData += chunk;
|
|
messageSize += chunk.length;
|
|
|
|
// Check size limits
|
|
if (this.config.maxMessageSize && messageSize > this.config.maxMessageSize) {
|
|
stream.unpipe();
|
|
return reject(new Error('Message size exceeds limit'));
|
|
}
|
|
});
|
|
|
|
stream.on('end', async () => {
|
|
try {
|
|
// Parse the email using mailparser
|
|
const parsedMail = await this.parseEmail(messageData);
|
|
|
|
// Emit message received event
|
|
this.emit('message', {
|
|
session,
|
|
mail: parsedMail,
|
|
rawData: messageData
|
|
});
|
|
|
|
resolve();
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
|
|
stream.on('error', (error) => {
|
|
reject(error);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Parse raw email data using mailparser
|
|
* @param rawData Raw email data
|
|
*/
|
|
private async parseEmail(rawData: string): Promise<any> {
|
|
// Use mailparser to parse the email
|
|
// We return 'any' here which will be treated as ExtendedParsedMail by consumers
|
|
return plugins.mailparser.simpleParser(rawData);
|
|
}
|
|
|
|
/**
|
|
* Check if an IP address is allowed to connect
|
|
* @param ip IP address
|
|
*/
|
|
private isIpAllowed(ip: string): boolean {
|
|
// Default to allowing all IPs if no restrictions
|
|
const defaultAllowed = ['0.0.0.0/0'];
|
|
|
|
// Check domain configs for IP restrictions
|
|
for (const domainConfig of this.config.domainConfigs) {
|
|
if (domainConfig.allowedIPs && domainConfig.allowedIPs.length > 0) {
|
|
// Check if IP matches any of the allowed IPs
|
|
for (const allowedIp of domainConfig.allowedIPs) {
|
|
if (this.ipMatchesRange(ip, allowedIp)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check against default allowed IPs
|
|
for (const allowedIp of defaultAllowed) {
|
|
if (this.ipMatchesRange(ip, allowedIp)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check if an IP matches a range
|
|
* @param ip IP address to check
|
|
* @param range IP range in CIDR notation
|
|
*/
|
|
private ipMatchesRange(ip: string, range: string): boolean {
|
|
try {
|
|
// Use the 'ip' package to check if IP is in range
|
|
return plugins.ip.cidrSubnet(range).contains(ip);
|
|
} catch (error) {
|
|
console.error(`Invalid IP range: ${range}`, error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a domain is handled by this server
|
|
* @param domain Domain to check
|
|
*/
|
|
private isDomainHandled(domain: string): boolean {
|
|
// Check if the domain is configured in any domain config
|
|
for (const domainConfig of this.config.domainConfigs) {
|
|
for (const configDomain of domainConfig.domains) {
|
|
if (this.domainMatches(domain, configDomain)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check if a domain matches a pattern (including wildcards)
|
|
* @param domain Domain to check
|
|
* @param pattern Pattern to match against
|
|
*/
|
|
private domainMatches(domain: string, pattern: string): boolean {
|
|
domain = domain.toLowerCase();
|
|
pattern = pattern.toLowerCase();
|
|
|
|
// Exact match
|
|
if (domain === pattern) {
|
|
return true;
|
|
}
|
|
|
|
// Wildcard match (*.example.com)
|
|
if (pattern.startsWith('*.')) {
|
|
const suffix = pattern.slice(2);
|
|
return domain.endsWith(suffix) && domain.length > suffix.length;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Authenticate a user
|
|
* @param auth Authentication data
|
|
*/
|
|
private authenticateUser(auth: IAuthData): boolean {
|
|
// Skip if no auth config
|
|
if (!this.config.auth) {
|
|
return true;
|
|
}
|
|
|
|
// Check if auth method is supported
|
|
if (this.config.auth.methods && !this.config.auth.methods.includes(auth.method as any)) {
|
|
return false;
|
|
}
|
|
|
|
// Check static user credentials
|
|
if (this.config.auth.users) {
|
|
const user = this.config.auth.users.find(u =>
|
|
u.username === auth.username && u.password === auth.password);
|
|
if (user) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// LDAP authentication would go here
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Event handler for connection
|
|
* @param handler Function to handle connection
|
|
*/
|
|
public onConnect(handler: (session: ISmtpSession, callback: (err?: Error) => void) => void): void {
|
|
// In actual implementation, this would connect to the server's 'connection' event
|
|
this.on('connect', handler);
|
|
}
|
|
|
|
/**
|
|
* Event handler for authentication
|
|
* @param handler Function to handle authentication
|
|
*/
|
|
public onAuth(handler: (auth: IAuthData, session: ISmtpSession, callback: (err?: Error, user?: any) => void) => void): void {
|
|
// In actual implementation, this would connect to the server's 'auth' event
|
|
this.on('auth', handler);
|
|
}
|
|
|
|
/**
|
|
* Event handler for MAIL FROM command
|
|
* @param handler Function to handle MAIL FROM
|
|
*/
|
|
public onMailFrom(handler: (address: { address: string; args: any }, session: ISmtpSession, callback: (err?: Error) => void) => void): void {
|
|
// In actual implementation, this would connect to the server's 'mail' event
|
|
this.on('mail', handler);
|
|
}
|
|
|
|
/**
|
|
* Event handler for RCPT TO command
|
|
* @param handler Function to handle RCPT TO
|
|
*/
|
|
public onRcptTo(handler: (address: { address: string; args: any }, session: ISmtpSession, callback: (err?: Error) => void) => void): void {
|
|
// In actual implementation, this would connect to the server's 'rcpt' event
|
|
this.on('rcpt', handler);
|
|
}
|
|
|
|
/**
|
|
* Event handler for DATA command
|
|
* @param handler Function to handle DATA
|
|
*/
|
|
public onData(handler: (stream: Readable, session: ISmtpSession, callback: (err?: Error) => void) => void): void {
|
|
// In actual implementation, this would connect to the server's 'data' event
|
|
this.on('dataReady', handler);
|
|
}
|
|
|
|
/**
|
|
* Update the server configuration
|
|
* @param config New configuration
|
|
*/
|
|
public updateConfig(config: Partial<ISmtpConfig>): void {
|
|
this.config = {
|
|
...this.config,
|
|
...config
|
|
};
|
|
|
|
// In a real implementation, this might require restarting the server
|
|
this.emit('configUpdated', this.config);
|
|
}
|
|
|
|
/**
|
|
* Get server statistics
|
|
*/
|
|
public getStats(): any {
|
|
return {
|
|
connections: this.incomingConnections.size,
|
|
// Additional stats would be included here
|
|
};
|
|
}
|
|
} |