feat(mailer-smtp): implement in-process SMTP server and management IPC integration
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartmta',
|
||||
version: '2.1.0',
|
||||
version: '2.2.0',
|
||||
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -73,6 +73,60 @@ interface IVersionInfo {
|
||||
smtp: string;
|
||||
}
|
||||
|
||||
// --- SMTP Server types ---
|
||||
|
||||
interface ISmtpServerConfig {
|
||||
hostname: string;
|
||||
ports: number[];
|
||||
securePort?: number;
|
||||
tlsCertPem?: string;
|
||||
tlsKeyPem?: string;
|
||||
maxMessageSize?: number;
|
||||
maxConnections?: number;
|
||||
maxRecipients?: number;
|
||||
connectionTimeoutSecs?: number;
|
||||
dataTimeoutSecs?: number;
|
||||
authEnabled?: boolean;
|
||||
maxAuthFailures?: number;
|
||||
socketTimeoutSecs?: number;
|
||||
processingTimeoutSecs?: number;
|
||||
rateLimits?: IRateLimitConfig;
|
||||
}
|
||||
|
||||
interface IRateLimitConfig {
|
||||
maxConnectionsPerIp?: number;
|
||||
maxMessagesPerSender?: number;
|
||||
maxAuthFailuresPerIp?: number;
|
||||
windowSecs?: number;
|
||||
}
|
||||
|
||||
interface IEmailData {
|
||||
type: 'inline' | 'file';
|
||||
base64?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface IEmailReceivedEvent {
|
||||
correlationId: string;
|
||||
sessionId: string;
|
||||
mailFrom: string;
|
||||
rcptTo: string[];
|
||||
data: IEmailData;
|
||||
remoteAddr: string;
|
||||
clientHostname: string | null;
|
||||
secure: boolean;
|
||||
authenticatedUser: string | null;
|
||||
securityResults: any | null;
|
||||
}
|
||||
|
||||
interface IAuthRequestEvent {
|
||||
correlationId: string;
|
||||
sessionId: string;
|
||||
username: string;
|
||||
password: string;
|
||||
remoteAddr: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe command map for the mailer-bin IPC bridge.
|
||||
*/
|
||||
@@ -128,6 +182,35 @@ type TMailerCommands = {
|
||||
};
|
||||
result: IEmailSecurityResult;
|
||||
};
|
||||
startSmtpServer: {
|
||||
params: ISmtpServerConfig;
|
||||
result: { started: boolean };
|
||||
};
|
||||
stopSmtpServer: {
|
||||
params: Record<string, never>;
|
||||
result: { stopped: boolean; wasRunning?: boolean };
|
||||
};
|
||||
emailProcessingResult: {
|
||||
params: {
|
||||
correlationId: string;
|
||||
accepted: boolean;
|
||||
smtpCode?: number;
|
||||
smtpMessage?: string;
|
||||
};
|
||||
result: { resolved: boolean };
|
||||
};
|
||||
authResult: {
|
||||
params: {
|
||||
correlationId: string;
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
result: { resolved: boolean };
|
||||
};
|
||||
configureRateLimits: {
|
||||
params: IRateLimitConfig;
|
||||
result: { configured: boolean };
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -314,6 +397,85 @@ export class RustSecurityBridge {
|
||||
}): Promise<IEmailSecurityResult> {
|
||||
return this.bridge.sendCommand('verifyEmail', opts);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// SMTP Server lifecycle
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Start the Rust SMTP server.
|
||||
* The server will listen on the configured ports and emit events for
|
||||
* emailReceived and authRequest that must be handled by the caller.
|
||||
*/
|
||||
public async startSmtpServer(config: ISmtpServerConfig): Promise<boolean> {
|
||||
const result = await this.bridge.sendCommand('startSmtpServer', config);
|
||||
return result?.started === true;
|
||||
}
|
||||
|
||||
/** Stop the Rust SMTP server. */
|
||||
public async stopSmtpServer(): Promise<void> {
|
||||
await this.bridge.sendCommand('stopSmtpServer', {} as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the result of email processing back to the Rust SMTP server.
|
||||
* This resolves a pending correlation-ID callback, allowing the Rust
|
||||
* server to send the SMTP response to the client.
|
||||
*/
|
||||
public async sendEmailProcessingResult(opts: {
|
||||
correlationId: string;
|
||||
accepted: boolean;
|
||||
smtpCode?: number;
|
||||
smtpMessage?: string;
|
||||
}): Promise<void> {
|
||||
await this.bridge.sendCommand('emailProcessingResult', opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the result of authentication validation back to the Rust SMTP server.
|
||||
*/
|
||||
public async sendAuthResult(opts: {
|
||||
correlationId: string;
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}): Promise<void> {
|
||||
await this.bridge.sendCommand('authResult', opts);
|
||||
}
|
||||
|
||||
/** Update rate limit configuration at runtime. */
|
||||
public async configureRateLimits(config: IRateLimitConfig): Promise<void> {
|
||||
await this.bridge.sendCommand('configureRateLimits', config);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Event registration — delegates to the underlying bridge EventEmitter
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Register a handler for emailReceived events from the Rust SMTP server.
|
||||
* These events fire when a complete email has been received and needs processing.
|
||||
*/
|
||||
public onEmailReceived(handler: (data: IEmailReceivedEvent) => void): void {
|
||||
this.bridge.on('management:emailReceived', handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler for authRequest events from the Rust SMTP server.
|
||||
* The handler must call sendAuthResult() with the correlationId.
|
||||
*/
|
||||
public onAuthRequest(handler: (data: IAuthRequestEvent) => void): void {
|
||||
this.bridge.on('management:authRequest', handler);
|
||||
}
|
||||
|
||||
/** Remove an emailReceived event handler. */
|
||||
public offEmailReceived(handler: (data: IEmailReceivedEvent) => void): void {
|
||||
this.bridge.off('management:emailReceived', handler);
|
||||
}
|
||||
|
||||
/** Remove an authRequest event handler. */
|
||||
public offAuthRequest(handler: (data: IAuthRequestEvent) => void): void {
|
||||
this.bridge.off('management:authRequest', handler);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export interfaces for consumers
|
||||
@@ -327,4 +489,9 @@ export type {
|
||||
IContentScanResult,
|
||||
IReputationResult as IRustReputationResult,
|
||||
IVersionInfo,
|
||||
ISmtpServerConfig,
|
||||
IRateLimitConfig,
|
||||
IEmailData,
|
||||
IEmailReceivedEvent,
|
||||
IAuthRequestEvent,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user