feat(mailer-smtp): implement in-process SMTP server and management IPC integration
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
import { DKIMCreator } from '../security/classes.dkimcreator.js';
|
||||
import { IPReputationChecker } from '../../security/classes.ipreputationchecker.js';
|
||||
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||
import type { IEmailReceivedEvent, IAuthRequestEvent, IEmailData } from '../../security/classes.rustsecuritybridge.js';
|
||||
// Deliverability types (IPWarmupManager and SenderReputationMonitor are optional external modules)
|
||||
interface IIPWarmupConfig {
|
||||
enabled?: boolean;
|
||||
@@ -396,134 +397,86 @@ export class UnifiedEmailServer extends EventEmitter {
|
||||
this.emit('started');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Ensure we have the necessary TLS options
|
||||
const hasTlsConfig = this.options.tls?.keyPath && this.options.tls?.certPath;
|
||||
|
||||
|
||||
// Prepare the certificate and key if available
|
||||
let key: string | undefined;
|
||||
let cert: string | undefined;
|
||||
|
||||
let tlsCertPem: string | undefined;
|
||||
let tlsKeyPem: string | undefined;
|
||||
|
||||
if (hasTlsConfig) {
|
||||
try {
|
||||
key = plugins.fs.readFileSync(this.options.tls.keyPath!, 'utf8');
|
||||
cert = plugins.fs.readFileSync(this.options.tls.certPath!, 'utf8');
|
||||
tlsKeyPem = plugins.fs.readFileSync(this.options.tls.keyPath!, 'utf8');
|
||||
tlsCertPem = plugins.fs.readFileSync(this.options.tls.certPath!, 'utf8');
|
||||
logger.log('info', 'TLS certificates loaded successfully');
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to load TLS certificates: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a SMTP server for each port
|
||||
for (const port of this.options.ports as number[]) {
|
||||
// Create a reference object to hold the MTA service during setup
|
||||
const mtaRef = {
|
||||
config: {
|
||||
smtp: {
|
||||
hostname: this.options.hostname
|
||||
},
|
||||
security: {
|
||||
checkIPReputation: false,
|
||||
verifyDkim: true,
|
||||
verifySpf: true,
|
||||
verifyDmarc: true
|
||||
}
|
||||
},
|
||||
// Security verification delegated to the Rust bridge
|
||||
dkimVerifier: {
|
||||
verify: async (rawMessage: string) => {
|
||||
try {
|
||||
const results = await this.rustBridge.verifyDkim(rawMessage);
|
||||
const first = results[0];
|
||||
return { isValid: first?.is_valid ?? false, domain: first?.domain ?? '' };
|
||||
} catch (err) {
|
||||
logger.log('warn', `Rust DKIM verification failed: ${(err as Error).message}`);
|
||||
return { isValid: false, domain: '' };
|
||||
}
|
||||
}
|
||||
},
|
||||
spfVerifier: {
|
||||
verifyAndApply: async (session: any) => {
|
||||
if (!session?.remoteAddress || session.remoteAddress === '127.0.0.1') {
|
||||
return true; // localhost — skip SPF
|
||||
}
|
||||
try {
|
||||
const result = await this.rustBridge.checkSpf({
|
||||
ip: session.remoteAddress,
|
||||
heloDomain: session.clientHostname || '',
|
||||
hostname: this.options.hostname,
|
||||
mailFrom: session.envelope?.mailFrom?.address || session.mailFrom || '',
|
||||
});
|
||||
return result.result === 'pass' || result.result === 'none' || result.result === 'neutral';
|
||||
} catch (err) {
|
||||
logger.log('warn', `Rust SPF check failed: ${(err as Error).message}`);
|
||||
return true; // Accept on error to avoid blocking mail
|
||||
}
|
||||
}
|
||||
},
|
||||
dmarcVerifier: {
|
||||
verify: async () => ({}),
|
||||
applyPolicy: () => true
|
||||
},
|
||||
processIncomingEmail: async (email: Email) => {
|
||||
// Process email using the new route-based system
|
||||
await this.processEmailByMode(email, {
|
||||
id: 'session-' + Math.random().toString(36).substring(2),
|
||||
state: SmtpState.FINISHED,
|
||||
mailFrom: email.from,
|
||||
rcptTo: email.to,
|
||||
emailData: email.toRFC822String(), // Use the proper method to get the full email content
|
||||
useTLS: false,
|
||||
connectionEnded: true,
|
||||
remoteAddress: '127.0.0.1',
|
||||
clientHostname: '',
|
||||
secure: false,
|
||||
authenticated: false,
|
||||
envelope: {
|
||||
mailFrom: { address: email.from, args: {} },
|
||||
rcptTo: email.to.map(recipient => ({ address: recipient, args: {} }))
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// Create server options
|
||||
const serverOptions = {
|
||||
port,
|
||||
hostname: this.options.hostname,
|
||||
key,
|
||||
cert
|
||||
};
|
||||
|
||||
// Create and start the SMTP server
|
||||
const smtpServer = createSmtpServer(mtaRef as any, serverOptions);
|
||||
this.servers.push(smtpServer);
|
||||
|
||||
// Start the server
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
try {
|
||||
// Leave this empty for now, smtpServer.start() is handled by the SMTPServer class internally
|
||||
// The server is started when it's created
|
||||
logger.log('info', `UnifiedEmailServer listening on port ${port}`);
|
||||
|
||||
// Event handlers are managed internally by the SmtpServer class
|
||||
// No need to access the private server property
|
||||
|
||||
resolve();
|
||||
} catch (err) {
|
||||
if ((err as any).code === 'EADDRINUSE') {
|
||||
logger.log('error', `Port ${port} is already in use`);
|
||||
reject(new Error(`Port ${port} is already in use`));
|
||||
} else {
|
||||
logger.log('error', `Error starting server on port ${port}: ${err.message}`);
|
||||
reject(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Start Rust SMTP server ---
|
||||
// Register event handlers for email reception and auth
|
||||
this.rustBridge.onEmailReceived(async (data) => {
|
||||
try {
|
||||
await this.handleRustEmailReceived(data);
|
||||
} catch (err) {
|
||||
logger.log('error', `Error handling email from Rust SMTP: ${(err as Error).message}`);
|
||||
// Send rejection back to Rust
|
||||
await this.rustBridge.sendEmailProcessingResult({
|
||||
correlationId: data.correlationId,
|
||||
accepted: false,
|
||||
smtpCode: 451,
|
||||
smtpMessage: 'Internal processing error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.rustBridge.onAuthRequest(async (data) => {
|
||||
try {
|
||||
await this.handleRustAuthRequest(data);
|
||||
} catch (err) {
|
||||
logger.log('error', `Error handling auth from Rust SMTP: ${(err as Error).message}`);
|
||||
await this.rustBridge.sendAuthResult({
|
||||
correlationId: data.correlationId,
|
||||
success: false,
|
||||
message: 'Internal auth error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Determine which ports need STARTTLS and which need implicit TLS
|
||||
const smtpPorts = (this.options.ports as number[]).filter(p => p !== 465);
|
||||
const securePort = (this.options.ports as number[]).find(p => p === 465);
|
||||
|
||||
const started = await this.rustBridge.startSmtpServer({
|
||||
hostname: this.options.hostname,
|
||||
ports: smtpPorts,
|
||||
securePort: securePort,
|
||||
tlsCertPem,
|
||||
tlsKeyPem,
|
||||
maxMessageSize: this.options.maxMessageSize || 10 * 1024 * 1024,
|
||||
maxConnections: this.options.maxConnections || this.options.maxClients || 100,
|
||||
maxRecipients: 100,
|
||||
connectionTimeoutSecs: this.options.connectionTimeout ? Math.floor(this.options.connectionTimeout / 1000) : 30,
|
||||
dataTimeoutSecs: 60,
|
||||
authEnabled: !!this.options.auth?.required || !!(this.options.auth?.users?.length),
|
||||
maxAuthFailures: 3,
|
||||
socketTimeoutSecs: this.options.socketTimeout ? Math.floor(this.options.socketTimeout / 1000) : 300,
|
||||
processingTimeoutSecs: 30,
|
||||
rateLimits: this.options.rateLimits ? {
|
||||
maxConnectionsPerIp: this.options.rateLimits.global?.maxConnectionsPerIP || 50,
|
||||
maxMessagesPerSender: this.options.rateLimits.global?.maxMessagesPerMinute || 100,
|
||||
maxAuthFailuresPerIp: this.options.rateLimits.global?.maxAuthFailuresPerIP || 5,
|
||||
windowSecs: 60,
|
||||
} : undefined,
|
||||
});
|
||||
|
||||
if (!started) {
|
||||
throw new Error('Failed to start Rust SMTP server');
|
||||
}
|
||||
|
||||
|
||||
logger.log('info', `Rust SMTP server listening on ports: ${smtpPorts.join(', ')}${securePort ? ` + ${securePort} (TLS)` : ''}`);
|
||||
logger.log('info', 'UnifiedEmailServer started successfully');
|
||||
this.emit('started');
|
||||
} catch (error) {
|
||||
@@ -587,6 +540,14 @@ export class UnifiedEmailServer extends EventEmitter {
|
||||
logger.log('info', 'Stopping UnifiedEmailServer');
|
||||
|
||||
try {
|
||||
// Stop the Rust SMTP server first
|
||||
try {
|
||||
await this.rustBridge.stopSmtpServer();
|
||||
logger.log('info', 'Rust SMTP server stopped');
|
||||
} catch (err) {
|
||||
logger.log('warn', `Error stopping Rust SMTP server: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// Clear the servers array - servers will be garbage collected
|
||||
this.servers = [];
|
||||
|
||||
@@ -623,11 +584,112 @@ export class UnifiedEmailServer extends EventEmitter {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Rust SMTP server event handlers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Handle an emailReceived event from the Rust SMTP server.
|
||||
* Decodes the email data, processes it through the routing system,
|
||||
* and sends back the result via the correlation-ID callback.
|
||||
*/
|
||||
private async handleRustEmailReceived(data: IEmailReceivedEvent): Promise<void> {
|
||||
const { correlationId, mailFrom, rcptTo, remoteAddr, clientHostname, secure, authenticatedUser } = data;
|
||||
|
||||
logger.log('info', `Rust SMTP received email from=${mailFrom} to=${rcptTo.join(',')} remote=${remoteAddr}`);
|
||||
|
||||
try {
|
||||
// Decode the email data
|
||||
let rawMessageBuffer: Buffer;
|
||||
if (data.data.type === 'inline' && data.data.base64) {
|
||||
rawMessageBuffer = Buffer.from(data.data.base64, 'base64');
|
||||
} else if (data.data.type === 'file' && data.data.path) {
|
||||
rawMessageBuffer = plugins.fs.readFileSync(data.data.path);
|
||||
// Clean up temp file
|
||||
try {
|
||||
plugins.fs.unlinkSync(data.data.path);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
} else {
|
||||
throw new Error('Invalid email data transport');
|
||||
}
|
||||
|
||||
// Build a session-like object for processEmailByMode
|
||||
const session: IExtendedSmtpSession = {
|
||||
id: data.sessionId || 'rust-' + Math.random().toString(36).substring(2),
|
||||
state: SmtpState.FINISHED,
|
||||
mailFrom: mailFrom,
|
||||
rcptTo: rcptTo,
|
||||
emailData: rawMessageBuffer.toString('utf8'),
|
||||
useTLS: secure,
|
||||
connectionEnded: false,
|
||||
remoteAddress: remoteAddr,
|
||||
clientHostname: clientHostname || '',
|
||||
secure: secure,
|
||||
authenticated: !!authenticatedUser,
|
||||
envelope: {
|
||||
mailFrom: { address: mailFrom, args: {} },
|
||||
rcptTo: rcptTo.map(addr => ({ address: addr, args: {} })),
|
||||
},
|
||||
};
|
||||
|
||||
if (authenticatedUser) {
|
||||
session.user = { username: authenticatedUser };
|
||||
}
|
||||
|
||||
// Process the email through the routing system
|
||||
await this.processEmailByMode(rawMessageBuffer, session);
|
||||
|
||||
// Send acceptance back to Rust
|
||||
await this.rustBridge.sendEmailProcessingResult({
|
||||
correlationId,
|
||||
accepted: true,
|
||||
smtpCode: 250,
|
||||
smtpMessage: '2.0.0 Message accepted for delivery',
|
||||
});
|
||||
} catch (err) {
|
||||
logger.log('error', `Failed to process email from Rust SMTP: ${(err as Error).message}`);
|
||||
await this.rustBridge.sendEmailProcessingResult({
|
||||
correlationId,
|
||||
accepted: false,
|
||||
smtpCode: 550,
|
||||
smtpMessage: `5.0.0 Processing failed: ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an authRequest event from the Rust SMTP server.
|
||||
* Validates credentials and sends back the result.
|
||||
*/
|
||||
private async handleRustAuthRequest(data: IAuthRequestEvent): Promise<void> {
|
||||
const { correlationId, username, password, remoteAddr } = data;
|
||||
|
||||
logger.log('info', `Rust SMTP auth request for user=${username} from=${remoteAddr}`);
|
||||
|
||||
// Check against configured users
|
||||
const users = this.options.auth?.users || [];
|
||||
const matched = users.find(
|
||||
u => u.username === username && u.password === password
|
||||
);
|
||||
|
||||
if (matched) {
|
||||
await this.rustBridge.sendAuthResult({
|
||||
correlationId,
|
||||
success: true,
|
||||
});
|
||||
} else {
|
||||
logger.log('warn', `Auth failed for user=${username} from=${remoteAddr}`);
|
||||
await this.rustBridge.sendAuthResult({
|
||||
correlationId,
|
||||
success: false,
|
||||
message: 'Invalid credentials',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify inbound email security (DKIM/SPF/DMARC) using the Rust bridge.
|
||||
* Falls back gracefully if the bridge is not running.
|
||||
|
||||
Reference in New Issue
Block a user