fix(mail): align queue, outbound hostname, and DKIM selector behavior across the mail server APIs

This commit is contained in:
2026-04-14 12:17:50 +00:00
parent 04e73c366c
commit 65ecd94540
15 changed files with 387 additions and 147 deletions

View File

@@ -1,12 +1,13 @@
import { logger } from '../../logger.js';
import { DKIMCreator } from '../security/classes.dkimcreator.js';
import { hasStorageManagerMethods, type IStorageManagerLike } from '../interfaces.storage.js';
import { DomainRegistry } from './classes.domain.registry.js';
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
import { Email } from '../core/classes.email.js';
/** External DcRouter interface shape used by DkimManager */
interface DcRouter {
storageManager: any;
storageManager?: IStorageManagerLike;
dnsServer?: any;
}
@@ -39,11 +40,19 @@ export class DkimManager {
let keyPair: { privateKey: string; publicKey: string };
try {
keyPair = await this.dkimCreator.readDKIMKeys(domain);
keyPair = selector === 'default'
? await this.dkimCreator.readDKIMKeys(domain)
: await this.dkimCreator.readDKIMKeysForSelector(domain, selector);
logger.log('info', `Using existing DKIM keys for domain: ${domain}`);
} catch (error) {
keyPair = await this.dkimCreator.createDKIMKeys();
await this.dkimCreator.createAndStoreDKIMKeys(domain);
} catch {
await this.dkimCreator.handleDKIMKeysForSelector(
domain,
selector,
domainConfig.dkim?.keySize || 2048,
);
keyPair = selector === 'default'
? await this.dkimCreator.readDKIMKeys(domain)
: await this.dkimCreator.readDKIMKeysForSelector(domain, selector);
logger.log('info', `Generated new DKIM keys for domain: ${domain}`);
}
@@ -106,10 +115,12 @@ export class DkimManager {
logger.log('info', `DKIM DNS handler registered for new selector: ${newSelector}._domainkey.${domain}`);
await this.dcRouter.storageManager.set(
`/email/dkim/${domain}/public.key`,
keyPair.publicKey
);
if (hasStorageManagerMethods(this.dcRouter.storageManager, ['set'])) {
await this.dcRouter.storageManager.set(
`/email/dkim/${domain}/public.key`,
keyPair.publicKey
);
}
}
this.dkimCreator.cleanupOldKeys(domain, 30).catch(error => {
@@ -127,8 +138,10 @@ export class DkimManager {
async handleDkimSigning(email: Email, domain: string, selector: string): Promise<void> {
try {
await this.dkimCreator.handleDKIMKeysForDomain(domain);
const { privateKey } = await this.dkimCreator.readDKIMKeys(domain);
await this.dkimCreator.handleDKIMKeysForSelector(domain, selector);
const { privateKey } = selector === 'default'
? await this.dkimCreator.readDKIMKeys(domain)
: await this.dkimCreator.readDKIMKeysForSelector(domain, selector);
const rawEmail = email.toRFC822String();
// Detect key type from PEM header

View File

@@ -1,5 +1,6 @@
import * as plugins from '../../plugins.js';
import type { IEmailDomainConfig } from './interfaces.js';
import type { IStorageManagerLike } from '../interfaces.storage.js';
import { logger } from '../../logger.js';
/** External DcRouter interface shape used by DnsManager */
interface IDcRouterLike {
@@ -8,12 +9,6 @@ interface IDcRouterLike {
options?: { dnsNsDomains?: string[]; dnsScopes?: string[] };
}
/** External StorageManager interface shape used by DnsManager */
interface IStorageManagerLike {
get(key: string): Promise<string | null>;
set(key: string, value: string): Promise<void>;
}
/**
* DNS validation result
*/
@@ -528,7 +523,7 @@ export class DnsManager {
try {
// Get DKIM DNS record from DKIMCreator
const dnsRecord = await dkimCreator.getDNSRecordForDomain(domain);
const dnsRecord = await dkimCreator.getDNSRecordForDomain(domain, selector);
// For internal-dns domains, register the DNS handler
if (domainConfig.dnsMode === 'internal-dns' && this.dcRouter.dnsServer) {
@@ -570,4 +565,4 @@ export class DnsManager {
}
}
}
}
}

View File

@@ -1,5 +1,6 @@
import * as plugins from '../../plugins.js';
import { EventEmitter } from 'node:events';
import { hasStorageManagerMethods, type IStorageManagerLike } from '../interfaces.storage.js';
import type { IEmailRoute, IEmailMatch, IEmailAction, IEmailContext } from './interfaces.js';
import type { Email } from '../core/classes.email.js';
@@ -9,7 +10,7 @@ import type { Email } from '../core/classes.email.js';
export class EmailRouter extends EventEmitter {
private routes: IEmailRoute[];
private patternCache: Map<string, boolean> = new Map();
private storageManager?: any; // StorageManager instance
private storageManager?: IStorageManagerLike;
private persistChanges: boolean;
/**
@@ -18,7 +19,7 @@ export class EmailRouter extends EventEmitter {
* @param options Router options
*/
constructor(routes: IEmailRoute[], options?: {
storageManager?: any;
storageManager?: IStorageManagerLike;
persistChanges?: boolean;
}) {
super();
@@ -27,7 +28,7 @@ export class EmailRouter extends EventEmitter {
this.persistChanges = options?.persistChanges ?? !!this.storageManager;
// If storage manager is provided, try to load persisted routes
if (this.storageManager) {
if (hasStorageManagerMethods(this.storageManager, ['get'])) {
this.loadRoutes({ merge: true }).catch(error => {
console.error(`Failed to load persisted routes: ${error.message}`);
});
@@ -394,7 +395,7 @@ export class EmailRouter extends EventEmitter {
* Save current routes to storage
*/
public async saveRoutes(): Promise<void> {
if (!this.storageManager) {
if (!hasStorageManagerMethods(this.storageManager, ['set'])) {
this.emit('persistenceWarning', 'Cannot save routes: StorageManager not configured');
return;
}
@@ -425,7 +426,7 @@ export class EmailRouter extends EventEmitter {
merge?: boolean; // Merge with existing routes
replace?: boolean; // Replace existing routes
}): Promise<IEmailRoute[]> {
if (!this.storageManager) {
if (!hasStorageManagerMethods(this.storageManager, ['get'])) {
this.emit('persistenceWarning', 'Cannot load routes: StorageManager not configured');
return [];
}
@@ -572,4 +573,4 @@ export class EmailRouter extends EventEmitter {
public getRoute(name: string): IEmailRoute | undefined {
return this.routes.find(r => r.name === name);
}
}
}

View File

@@ -8,17 +8,18 @@ import {
SecurityEventType
} from '../../security/index.js';
import { DKIMCreator } from '../security/classes.dkimcreator.js';
import { hasStorageManagerMethods, type IStorageManagerLike } from '../interfaces.storage.js';
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
import type { IEmailReceivedEvent, IAuthRequestEvent, IEmailData } from '../../security/classes.rustsecuritybridge.js';
import type { IEmailReceivedEvent, IAuthRequestEvent } from '../../security/classes.rustsecuritybridge.js';
import { EmailRouter } from './classes.email.router.js';
import type { IEmailRoute, IEmailAction, IEmailContext, IEmailDomainConfig } from './interfaces.js';
import type { IEmailRoute, IEmailContext, IEmailDomainConfig } from './interfaces.js';
import { Email } from '../core/classes.email.js';
import { DomainRegistry } from './classes.domain.registry.js';
import { DnsManager } from './classes.dns.manager.js';
import { BounceManager, BounceType, BounceCategory } from '../core/classes.bouncemanager.js';
import type { ISmtpSendResult, IOutboundEmail } from '../../security/classes.rustsecuritybridge.js';
import { MultiModeDeliverySystem, type IMultiModeDeliveryOptions } from '../delivery/classes.delivery.system.js';
import { UnifiedDeliveryQueue, type IQueueOptions } from '../delivery/classes.delivery.queue.js';
import { MultiModeDeliverySystem, type IDeliveryStats, type IMultiModeDeliveryOptions } from '../delivery/classes.delivery.system.js';
import { UnifiedDeliveryQueue, type IQueueItem, type IQueueOptions, type IQueueStats } from '../delivery/classes.delivery.queue.js';
import { UnifiedRateLimiter, type IHierarchicalRateLimits } from '../delivery/classes.unified.rate.limiter.js';
import { SmtpState } from '../delivery/interfaces.js';
import type { EmailProcessingMode, ISmtpSession as IBaseSmtpSession } from '../delivery/interfaces.js';
@@ -28,7 +29,7 @@ import { DkimManager } from './classes.dkim.manager.js';
/** External DcRouter interface shape used by UnifiedEmailServer */
interface DcRouter {
storageManager: any;
storageManager: IStorageManagerLike;
dnsServer?: any;
options?: any;
}
@@ -49,11 +50,14 @@ export interface IExtendedSmtpSession extends ISmtpSession {
export interface IUnifiedEmailServerOptions {
// Base server options
ports: number[];
/** Public SMTP hostname used for greeting/banner and as the default outbound identity. */
hostname: string;
domains: IEmailDomainConfig[]; // Domain configurations
banner?: string;
debug?: boolean;
useSocketHandler?: boolean; // Use socket-handler mode instead of port listening
/** Persist router changes back into storage when a storage manager is available. */
persistRoutes?: boolean;
// Authentication options
auth?: {
@@ -92,6 +96,8 @@ export interface IUnifiedEmailServerOptions {
// Outbound settings
outbound?: {
/** Override the SMTP identity used for outbound delivery. Defaults to `hostname`. */
hostname?: string;
maxConnections?: number;
connectionTimeout?: number;
socketTimeout?: number;
@@ -99,6 +105,9 @@ export interface IUnifiedEmailServerOptions {
defaultFrom?: string;
};
// Delivery queue
queue?: IQueueOptions;
// Rate limiting (global limits, can be overridden per domain)
rateLimits?: IHierarchicalRateLimits;
}
@@ -206,7 +215,7 @@ export class UnifiedEmailServer extends EventEmitter {
// Initialize email router with routes and storage manager
this.emailRouter = new EmailRouter(options.routes || [], {
storageManager: dcRouter.storageManager,
persistChanges: true
persistChanges: options.persistRoutes ?? hasStorageManagerMethods(dcRouter.storageManager, ['get', 'set'])
});
// Initialize rate limiter
@@ -226,7 +235,8 @@ export class UnifiedEmailServer extends EventEmitter {
storageType: 'memory', // Default to memory storage
maxRetries: 3,
baseRetryDelay: 300000, // 5 minutes
maxRetryDelay: 3600000 // 1 hour
maxRetryDelay: 3600000, // 1 hour
...options.queue,
};
this.deliveryQueue = new UnifiedDeliveryQueue(queueOptions);
@@ -277,6 +287,14 @@ export class UnifiedEmailServer extends EventEmitter {
// We'll create the SMTP servers during the start() method
}
private getAdvertisedHostname(): string {
return this.options.hostname;
}
private getOutboundHostname(): string {
return this.options.outbound?.hostname || this.options.hostname;
}
/**
* Send an outbound email via the Rust SMTP client.
* Uses connection pooling in the Rust binary for efficiency.
@@ -314,7 +332,7 @@ export class UnifiedEmailServer extends EventEmitter {
host,
port,
secure: port === 465,
domain: this.options.hostname,
domain: this.getOutboundHostname(),
auth: options?.auth,
email: outboundEmail,
dkim,
@@ -455,7 +473,7 @@ export class UnifiedEmailServer extends EventEmitter {
const securePort = (this.options.ports as number[]).find(p => p === 465);
const started = await this.rustBridge.startSmtpServer({
hostname: this.options.hostname,
hostname: this.getAdvertisedHostname(),
ports: smtpPorts,
securePort: securePort,
tlsCertPem,
@@ -518,6 +536,9 @@ export class UnifiedEmailServer extends EventEmitter {
logger.log('info', 'Email delivery queue shut down');
}
this.bounceManager.stop();
logger.log('info', 'Bounce manager stopped');
// Close all Rust SMTP client connection pools
try {
await this.rustBridge.closeSmtpPool();
@@ -973,6 +994,10 @@ export class UnifiedEmailServer extends EventEmitter {
this.emailRouter.updateRoutes(routes);
}
public getEmailRoutes(): IEmailRoute[] {
return this.emailRouter.getRoutes();
}
/**
* Get server statistics
*/
@@ -980,6 +1005,22 @@ export class UnifiedEmailServer extends EventEmitter {
return { ...this.stats };
}
public getQueueStats(): IQueueStats {
return this.deliveryQueue.getStats();
}
public getQueueItems(): IQueueItem[] {
return this.deliveryQueue.listItems();
}
public getQueueItem(id: string): IQueueItem | undefined {
return this.deliveryQueue.getItem(id);
}
public getDeliveryStats(): IDeliveryStats {
return this.deliverySystem.getStats();
}
/**
* Get domain registry
*/
@@ -1039,11 +1080,10 @@ export class UnifiedEmailServer extends EventEmitter {
// Sign with DKIM if configured
if (mode === 'mta' && route?.action.options?.mtaOptions?.dkimSign) {
const domain = email.from.split('@')[1];
await this.dkimManager.handleDkimSigning(email, domain, route.action.options.mtaOptions.dkimOptions?.keySelector || 'mta');
await this.dkimManager.handleDkimSigning(email, domain, route.action.options.mtaOptions.dkimOptions?.keySelector || 'default');
}
const id = plugins.uuid.v4();
await this.deliveryQueue.enqueue(email, mode, route);
const id = await this.deliveryQueue.enqueue(email, mode, route);
logger.log('info', `Email queued with ID: ${id}`);
return id;