fix(mail): align queue, outbound hostname, and DKIM selector behavior across the mail server APIs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user