feat(mailer-smtp): add SCRAM-SHA-256 auth, Ed25519 DKIM, opportunistic TLS, SNI cert selection, pipelining and delivery/bridge improvements

This commit is contained in:
2026-02-11 10:11:43 +00:00
parent 7908cbaefa
commit b10597fd5e
28 changed files with 1849 additions and 153 deletions

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartmta',
version: '5.0.0',
version: '5.1.0',
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
}

View File

@@ -7,10 +7,12 @@ import {
SecurityEventType
} from '../../security/index.js';
import { UnifiedDeliveryQueue, type IQueueItem } from './classes.delivery.queue.js';
import type { Email } from '../core/classes.email.js';
import { Email } from '../core/classes.email.js';
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
const dns = plugins.dns;
/**
* Delivery status enumeration
*/
@@ -480,39 +482,119 @@ export class MultiModeDeliverySystem extends EventEmitter {
}
}
/**
* Resolve MX records for a domain, sorted by priority (lowest first).
* Falls back to the domain itself as an A record per RFC 5321.
*/
private async resolveMxForDomain(domain: string): Promise<Array<{ exchange: string; priority: number }>> {
const resolver = new dns.promises.Resolver();
try {
const mxRecords = await resolver.resolveMx(domain);
return mxRecords.sort((a, b) => a.priority - b.priority);
} catch (err) {
logger.log('warn', `No MX records for ${domain}, falling back to A record`);
return [{ exchange: domain, priority: 0 }];
}
}
/**
* Group recipient addresses by their domain part.
*/
private groupRecipientsByDomain(recipients: string[]): Map<string, string[]> {
const groups = new Map<string, string[]>();
for (const rcpt of recipients) {
const domain = rcpt.split('@')[1]?.toLowerCase();
if (!domain) continue;
const list = groups.get(domain) || [];
list.push(rcpt);
groups.set(domain, list);
}
return groups;
}
/**
* Default handler for MTA mode delivery
* @param item Queue item
*/
private async handleMtaDelivery(item: IQueueItem): Promise<any> {
logger.log('info', `MTA delivery for item ${item.id}`);
const email = item.processingResult as Email;
const route = item.route;
try {
// Apply DKIM signing if configured in the route
if (item.route?.action.options?.mtaOptions?.dkimSign) {
await this.applyDkimSigning(email, item.route.action.options.mtaOptions);
}
// In a full implementation, this would use the MTA service
// For now, we'll simulate a successful delivery
logger.log('info', `Email processed by MTA: ${email.subject} to ${email.getAllRecipients().join(', ')}`);
// Note: The MTA implementation would handle actual local delivery
// Simulate successful delivery
return {
recipients: email.getAllRecipients().length,
subject: email.subject,
dkimSigned: !!item.route?.action.options?.mtaOptions?.dkimSign
};
} catch (error: any) {
logger.log('error', `Failed to process email in MTA mode: ${error.message}`);
throw error;
if (!this.emailServer) {
throw new Error('No email server available for MTA delivery');
}
// Build DKIM options from route config
const dkimDomain = item.route?.action.options?.mtaOptions?.dkimSign
? (item.route.action.options.mtaOptions.dkimOptions?.domainName || email.from.split('@')[1])
: undefined;
const dkimSelector = item.route?.action.options?.mtaOptions?.dkimOptions?.keySelector || 'default';
const allRecipients = email.getAllRecipients();
if (allRecipients.length === 0) {
throw new Error('No recipients specified for MTA delivery');
}
const domainGroups = this.groupRecipientsByDomain(allRecipients);
const results: Array<{ domain: string; success: boolean; error?: string; accepted?: string[]; rejected?: string[] }> = [];
for (const [domain, recipients] of domainGroups) {
const mxHosts = await this.resolveMxForDomain(domain);
let delivered = false;
let lastError: string | undefined;
for (const mx of mxHosts) {
try {
logger.log('info', `MTA: trying MX ${mx.exchange}:25 for domain ${domain} (priority ${mx.priority})`);
// Create a temporary Email scoped to this domain's recipients
const domainEmail = new Email({
from: email.from,
to: recipients.filter(r => email.to.includes(r)),
cc: recipients.filter(r => (email.cc || []).includes(r)),
bcc: recipients.filter(r => (email.bcc || []).includes(r)),
subject: email.subject,
text: email.text,
html: email.html,
});
const result = await this.emailServer.sendOutboundEmail(mx.exchange, 25, domainEmail, {
dkimDomain,
dkimSelector,
});
results.push({
domain,
success: true,
accepted: result.accepted,
rejected: result.rejected,
});
delivered = true;
logger.log('info', `MTA: delivered to ${domain} via ${mx.exchange}`);
break;
} catch (err: any) {
lastError = err.message;
logger.log('warn', `MTA: MX ${mx.exchange} failed for ${domain}: ${err.message}`);
}
}
if (!delivered) {
results.push({ domain, success: false, error: lastError });
logger.log('error', `MTA: all MX hosts failed for ${domain}`);
}
}
const allFailed = results.every(r => !r.success);
if (allFailed) {
const summary = results.map(r => `${r.domain}: ${r.error}`).join('; ');
throw new Error(`MTA delivery failed for all domains: ${summary}`);
}
return {
recipients: allRecipients.length,
domainResults: results,
};
}
/**
@@ -584,16 +666,10 @@ export class MultiModeDeliverySystem extends EventEmitter {
await this.applyDkimSigning(email, item.route.action.options?.mtaOptions || {});
}
logger.log('info', `Email successfully processed in store-and-forward mode`);
// Simulate successful delivery
return {
recipients: email.getAllRecipients().length,
subject: email.subject,
scanned: !!route?.action.options?.contentScanning,
transformed: !!(route?.action.options?.transformations && route?.action.options?.transformations.length > 0),
dkimSigned: !!(item.route?.action.options?.mtaOptions?.dkimSign || item.route?.action.process?.dkim)
};
logger.log('info', `Email successfully processed in store-and-forward mode, delivering via MTA`);
// After scanning + transformations, deliver via MTA
return await this.handleMtaDelivery(item);
} catch (error: any) {
logger.log('error', `Failed to process email: ${error.message}`);
throw error;

View File

@@ -131,11 +131,15 @@ export class DkimManager {
const { privateKey } = await this.dkimCreator.readDKIMKeys(domain);
const rawEmail = email.toRFC822String();
// Detect key type from PEM header
const keyType = privateKey.includes('ED25519') ? 'ed25519' : 'rsa';
const signResult = await this.rustBridge.signDkim({
rawMessage: rawEmail,
domain,
selector,
privateKey,
keyType,
});
if (signResult.header) {

View File

@@ -18,6 +18,7 @@ export interface IActionExecutorDeps {
auth?: { user: string; pass: string };
dkimDomain?: string;
dkimSelector?: string;
tlsOpportunistic?: boolean;
}) => Promise<ISmtpSendResult>;
bounceManager: BounceManager;
deliveryQueue: UnifiedDeliveryQueue;

View File

@@ -285,6 +285,7 @@ export class UnifiedEmailServer extends EventEmitter {
auth?: { user: string; pass: string };
dkimDomain?: string;
dkimSelector?: string;
tlsOpportunistic?: boolean;
}): Promise<ISmtpSendResult> {
// Build DKIM config if domain has keys
let dkim: { domain: string; selector: string; privateKey: string } | undefined;
@@ -321,6 +322,7 @@ export class UnifiedEmailServer extends EventEmitter {
socketTimeoutSecs: Math.floor((this.options.outbound?.socketTimeout || 120000) / 1000),
poolKey: `${host}:${port}`,
maxPoolConnections: this.options.outbound?.maxConnections || 10,
tlsOpportunistic: options?.tlsOpportunistic ?? (port === 25),
});
}
@@ -416,6 +418,22 @@ export class UnifiedEmailServer extends EventEmitter {
}
}
});
this.rustBridge.onScramCredentialRequest(async (data) => {
try {
await this.handleScramCredentialRequest(data);
} catch (err) {
logger.log('error', `Error handling SCRAM credential request: ${(err as Error).message}`);
try {
await this.rustBridge.sendScramCredentialResult({
correlationId: data.correlationId,
found: false,
});
} catch (sendErr) {
logger.log('warn', `Could not send SCRAM credential rejection: ${(sendErr as Error).message}`);
}
}
});
}
private async startSmtpServer(): Promise<void> {
@@ -622,6 +640,53 @@ export class UnifiedEmailServer extends EventEmitter {
}
}
/**
* Handle a SCRAM credential request from the Rust SMTP server.
* Computes SCRAM-SHA-256 credentials from the stored password for the given user.
*/
private async handleScramCredentialRequest(data: { correlationId: string; username: string; remoteAddr: string }): Promise<void> {
const { correlationId, username, remoteAddr } = data;
const crypto = await import('crypto');
logger.log('info', `SCRAM credential request for user=${username} from=${remoteAddr}`);
const users = this.options.auth?.users || [];
const matched = users.find(u => u.username === username);
if (!matched) {
await this.rustBridge.sendScramCredentialResult({
correlationId,
found: false,
});
return;
}
// Compute SCRAM-SHA-256 credentials from plaintext password
const salt = crypto.randomBytes(16);
const iterations = 4096;
// SaltedPassword = PBKDF2-HMAC-SHA256(password, salt, iterations, 32)
const saltedPassword = crypto.pbkdf2Sync(matched.password, salt, iterations, 32, 'sha256');
// ClientKey = HMAC-SHA256(SaltedPassword, "Client Key")
const clientKey = crypto.createHmac('sha256', saltedPassword).update('Client Key').digest();
// StoredKey = SHA256(ClientKey)
const storedKey = crypto.createHash('sha256').update(clientKey).digest();
// ServerKey = HMAC-SHA256(SaltedPassword, "Server Key")
const serverKey = crypto.createHmac('sha256', saltedPassword).update('Server Key').digest();
await this.rustBridge.sendScramCredentialResult({
correlationId,
found: true,
salt: salt.toString('base64'),
iterations,
storedKey: storedKey.toString('base64'),
serverKey: serverKey.toString('base64'),
});
}
/**
* Verify inbound email security (DKIM/SPF/DMARC) using pre-computed Rust results
* or falling back to IPC call if no pre-computed results are available.

View File

@@ -115,7 +115,7 @@ export class DKIMCreator {
}
}
// Create a DKIM key pair - changed to public for API access
// Create an RSA DKIM key pair - changed to public for API access
public async createDKIMKeys(): Promise<{ privateKey: string; publicKey: string }> {
const { privateKey, publicKey } = await generateKeyPair('rsa', {
modulusLength: 2048,
@@ -126,6 +126,16 @@ export class DKIMCreator {
return { privateKey, publicKey };
}
// Create an Ed25519 DKIM key pair (RFC 8463)
public async createEd25519Keys(): Promise<{ privateKey: string; publicKey: string }> {
const { privateKey, publicKey } = await generateKeyPair('ed25519', {
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
return { privateKey, publicKey };
}
// Store a DKIM key pair - uses storage manager if available, else disk
public async storeDKIMKeys(
privateKey: string,
@@ -176,8 +186,11 @@ export class DKIMCreator {
.replace(pemFooter, '')
.replace(/\n/g, '');
// Detect key type from PEM header
const keyAlgo = keys.privateKey.includes('ED25519') || keys.publicKey.length < 200 ? 'ed25519' : 'rsa';
// Now generate the DKIM DNS TXT record
const dnsRecordValue = `v=DKIM1; h=sha256; k=rsa; p=${keyContents}`;
const dnsRecordValue = `v=DKIM1; h=sha256; k=${keyAlgo}; p=${keyContents}`;
return {
name: `mta._domainkey.${domainArg}`,
@@ -375,8 +388,11 @@ export class DKIMCreator {
.replace(pemFooter, '')
.replace(/\n/g, '');
// Detect key type from PEM header
const keyAlgo = keys.privateKey.includes('ED25519') || keys.publicKey.length < 200 ? 'ed25519' : 'rsa';
// Generate the DKIM DNS TXT record
const dnsRecordValue = `v=DKIM1; h=sha256; k=rsa; p=${keyContents}`;
const dnsRecordValue = `v=DKIM1; h=sha256; k=${keyAlgo}; p=${keyContents}`;
return {
name: `${selector}._domainkey.${domain}`,

View File

@@ -95,11 +95,12 @@ interface ISmtpSendOptions {
domain?: string;
auth?: { user: string; pass: string; method?: string };
email: IOutboundEmail;
dkim?: { domain: string; selector: string; privateKey: string };
dkim?: { domain: string; selector: string; privateKey: string; keyType?: string };
connectionTimeoutSecs?: number;
socketTimeoutSecs?: number;
poolKey?: string;
maxPoolConnections?: number;
tlsOpportunistic?: boolean;
}
interface ISmtpSendRawOptions {
@@ -147,6 +148,7 @@ interface ISmtpServerConfig {
securePort?: number;
tlsCertPem?: string;
tlsKeyPem?: string;
additionalTlsCerts?: Array<{ domains: string[]; certPem: string; keyPem: string }>;
maxMessageSize?: number;
maxConnections?: number;
maxRecipients?: number;
@@ -193,6 +195,13 @@ interface IAuthRequestEvent {
remoteAddr: string;
}
interface IScramCredentialRequestEvent {
correlationId: string;
sessionId: string;
username: string;
remoteAddr: string;
}
/**
* Type-safe command map for the mailer-bin IPC bridge.
*/
@@ -222,7 +231,7 @@ type TMailerCommands = {
result: IDkimVerificationResult[];
};
signDkim: {
params: { rawMessage: string; domain: string; selector?: string; privateKey: string };
params: { rawMessage: string; domain: string; selector?: string; privateKey: string; keyType?: string };
result: { header: string; signedMessage: string };
};
checkSpf: {
@@ -273,6 +282,17 @@ type TMailerCommands = {
};
result: { resolved: boolean };
};
scramCredentialResult: {
params: {
correlationId: string;
found: boolean;
salt?: string;
iterations?: number;
storedKey?: string;
serverKey?: string;
};
result: { resolved: boolean };
};
configureRateLimits: {
params: IRateLimitConfig;
result: { configured: boolean };
@@ -706,12 +726,13 @@ export class RustSecurityBridge extends EventEmitter {
return this.bridge.sendCommand('verifyDkim', { rawMessage });
}
/** Sign an email with DKIM. */
/** Sign an email with DKIM (RSA or Ed25519). */
public async signDkim(opts: {
rawMessage: string;
domain: string;
selector?: string;
privateKey: string;
keyType?: string;
}): Promise<{ header: string; signedMessage: string }> {
this.ensureRunning();
return this.bridge.sendCommand('signDkim', opts);
@@ -829,6 +850,22 @@ export class RustSecurityBridge extends EventEmitter {
await this.bridge.sendCommand('authResult', opts);
}
/**
* Send SCRAM credentials back to the Rust SMTP server.
* Values (salt, storedKey, serverKey) must be base64-encoded.
*/
public async sendScramCredentialResult(opts: {
correlationId: string;
found: boolean;
salt?: string;
iterations?: number;
storedKey?: string;
serverKey?: string;
}): Promise<void> {
this.ensureRunning();
await this.bridge.sendCommand('scramCredentialResult', opts);
}
/** Update rate limit configuration at runtime. */
public async configureRateLimits(config: IRateLimitConfig): Promise<void> {
this.ensureRunning();
@@ -855,6 +892,14 @@ export class RustSecurityBridge extends EventEmitter {
this.bridge.on('management:authRequest', handler);
}
/**
* Register a handler for scramCredentialRequest events from the Rust SMTP server.
* The handler must call sendScramCredentialResult() with the correlationId.
*/
public onScramCredentialRequest(handler: (data: IScramCredentialRequestEvent) => void): void {
this.bridge.on('management:scramCredentialRequest', handler);
}
/** Remove an emailReceived event handler. */
public offEmailReceived(handler: (data: IEmailReceivedEvent) => void): void {
this.bridge.off('management:emailReceived', handler);
@@ -864,6 +909,11 @@ export class RustSecurityBridge extends EventEmitter {
public offAuthRequest(handler: (data: IAuthRequestEvent) => void): void {
this.bridge.off('management:authRequest', handler);
}
/** Remove a scramCredentialRequest event handler. */
public offScramCredentialRequest(handler: (data: IScramCredentialRequestEvent) => void): void {
this.bridge.off('management:scramCredentialRequest', handler);
}
}
// Re-export interfaces for consumers
@@ -882,6 +932,7 @@ export type {
IEmailData,
IEmailReceivedEvent,
IAuthRequestEvent,
IScramCredentialRequestEvent,
IOutboundEmail,
ISmtpSendResult,
ISmtpSendOptions,