feat(email): add persistent smartmta storage and runtime-managed email domain syncing
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.17.9',
|
||||
version: '13.18.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type IUnifiedEmailServerOptions,
|
||||
type IEmailRoute,
|
||||
type IEmailDomainConfig,
|
||||
type IStorageManagerLike,
|
||||
} from '@push.rocks/smartmta';
|
||||
import { logger } from './logger.js';
|
||||
import { StorageBackedCertManager } from './classes.storage-cert-manager.js';
|
||||
@@ -29,7 +30,7 @@ import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/
|
||||
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
||||
import { DnsManager } from './dns/manager.dns.js';
|
||||
import { AcmeConfigManager } from './acme/manager.acme-config.js';
|
||||
import { EmailDomainManager } from './email/classes.email-domain.manager.js';
|
||||
import { EmailDomainManager, SmartMtaStorageManager } from './email/index.js';
|
||||
|
||||
export interface IDcRouterOptions {
|
||||
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
||||
@@ -248,15 +249,13 @@ export class DcRouter {
|
||||
public radiusServer?: RadiusServer;
|
||||
public opsServer!: OpsServer;
|
||||
public metricsManager?: MetricsManager;
|
||||
private emailEventSubscriptions: Array<{
|
||||
emitter: { off(eventName: string, listener: (...args: any[]) => void): void };
|
||||
eventName: string;
|
||||
listener: (...args: any[]) => void;
|
||||
}> = [];
|
||||
|
||||
// Compatibility shim for smartmta's DkimManager which calls dcRouter.storageManager.set()
|
||||
public storageManager: any = {
|
||||
get: async (_key: string) => null,
|
||||
set: async (_key: string, _value: string) => {
|
||||
// DKIM keys from smartmta — logged but not yet migrated to smartdata
|
||||
logger.log('debug', `storageManager.set() called (compat shim) for key: ${_key}`);
|
||||
},
|
||||
};
|
||||
public storageManager: IStorageManagerLike;
|
||||
|
||||
// Unified database (smartdata + LocalSmartDb or external MongoDB)
|
||||
public dcRouterDb?: DcRouterDb;
|
||||
@@ -329,6 +328,10 @@ export class DcRouter {
|
||||
|
||||
// Resolve all data paths from baseDir
|
||||
this.resolvedPaths = paths.resolvePaths(this.options.baseDir);
|
||||
paths.ensureDataDirectories(this.resolvedPaths);
|
||||
this.storageManager = new SmartMtaStorageManager(
|
||||
plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-storage')
|
||||
);
|
||||
|
||||
// Initialize service manager and register all services
|
||||
this.serviceManager = new plugins.taskbuffer.ServiceManager({
|
||||
@@ -452,9 +455,13 @@ export class DcRouter {
|
||||
.dependsOn('DcRouterDb')
|
||||
.withStart(async () => {
|
||||
this.emailDomainManager = new EmailDomainManager(this);
|
||||
await this.emailDomainManager.start();
|
||||
})
|
||||
.withStop(async () => {
|
||||
this.emailDomainManager = undefined;
|
||||
if (this.emailDomainManager) {
|
||||
await this.emailDomainManager.stop();
|
||||
this.emailDomainManager = undefined;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -610,19 +617,20 @@ export class DcRouter {
|
||||
|
||||
// Email Server: optional, depends on SmartProxy
|
||||
if (this.options.emailConfig) {
|
||||
const emailServiceDeps = ['SmartProxy', 'MetricsManager'];
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
emailServiceDeps.push('EmailDomainManager');
|
||||
}
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('EmailServer')
|
||||
.optional()
|
||||
.dependsOn('SmartProxy')
|
||||
.dependsOn(...emailServiceDeps)
|
||||
.withStart(async () => {
|
||||
await this.setupUnifiedEmailHandling();
|
||||
})
|
||||
.withStop(async () => {
|
||||
if (this.emailServer) {
|
||||
if ((this.emailServer as any).deliverySystem) {
|
||||
(this.emailServer as any).deliverySystem.removeAllListeners();
|
||||
}
|
||||
this.emailServer.removeAllListeners();
|
||||
this.clearEmailEventSubscriptions();
|
||||
await this.emailServer.stop();
|
||||
this.emailServer = undefined;
|
||||
}
|
||||
@@ -636,7 +644,7 @@ export class DcRouter {
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('DnsServer')
|
||||
.optional()
|
||||
.dependsOn('SmartProxy')
|
||||
.dependsOn('SmartProxy', ...(this.options.emailConfig ? ['EmailServer'] : []))
|
||||
.withStart(async () => {
|
||||
await this.setupDnsWithSocketHandler();
|
||||
})
|
||||
@@ -1511,40 +1519,74 @@ export class DcRouter {
|
||||
...this.options.emailConfig,
|
||||
domains: transformedDomains,
|
||||
ports: this.options.emailConfig.ports.map(port => portMapping[port] || port + 10000),
|
||||
hostname: 'localhost' // Listen on localhost for SmartProxy forwarding
|
||||
persistRoutes: this.options.emailConfig.persistRoutes ?? false,
|
||||
queue: {
|
||||
storageType: 'disk',
|
||||
persistentPath: plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-queue'),
|
||||
...this.options.emailConfig.queue,
|
||||
},
|
||||
};
|
||||
|
||||
// Create unified email server
|
||||
this.emailServer = new UnifiedEmailServer(this, emailConfig);
|
||||
this.clearEmailEventSubscriptions();
|
||||
|
||||
// Set up error handling
|
||||
this.emailServer.on('error', (err: Error) => {
|
||||
this.addEmailEventSubscription(this.emailServer, 'error', (err: Error) => {
|
||||
logger.log('error', `UnifiedEmailServer error: ${err.message}`);
|
||||
});
|
||||
|
||||
// Start the server
|
||||
await this.emailServer.start();
|
||||
|
||||
// Wire delivery events to MetricsManager and logger
|
||||
if (this.metricsManager && this.emailServer.deliverySystem) {
|
||||
this.emailServer.deliverySystem.on('deliveryStart', (item: any) => {
|
||||
this.metricsManager!.trackEmailReceived(item?.from);
|
||||
logger.log('info', `Email delivery started: ${item?.from} → ${item?.to}`, { zone: 'email' });
|
||||
});
|
||||
this.emailServer.deliverySystem.on('deliverySuccess', (item: any) => {
|
||||
this.metricsManager!.trackEmailSent(item?.to);
|
||||
logger.log('info', `Email delivered to ${item?.to}`, { zone: 'email' });
|
||||
});
|
||||
this.emailServer.deliverySystem.on('deliveryFailed', (item: any, error: any) => {
|
||||
this.metricsManager!.trackEmailFailed(item?.to, error?.message);
|
||||
logger.log('warn', `Email delivery failed to ${item?.to}: ${error?.message}`, { zone: 'email' });
|
||||
});
|
||||
}
|
||||
// Wire delivery events to MetricsManager and logger using smartmta's public queue APIs.
|
||||
if (this.metricsManager && this.emailServer) {
|
||||
this.emailServer.on('bounceProcessed', () => {
|
||||
const getEnvelope = (item: { processingResult?: any; lastError?: string }) => {
|
||||
const emailLike = item?.processingResult;
|
||||
const from = emailLike?.from || emailLike?.email?.from || '';
|
||||
const recipients = Array.isArray(emailLike?.to)
|
||||
? emailLike.to
|
||||
: Array.isArray(emailLike?.email?.to)
|
||||
? emailLike.email.to
|
||||
: [];
|
||||
return {
|
||||
from,
|
||||
recipients: recipients.filter(Boolean),
|
||||
};
|
||||
};
|
||||
const updateQueueSize = () => {
|
||||
this.metricsManager!.updateQueueSize(this.emailServer!.getQueueStats().queueSize);
|
||||
};
|
||||
|
||||
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemEnqueued', (item: any) => {
|
||||
const envelope = getEnvelope(item);
|
||||
this.metricsManager!.trackEmailReceived(envelope.from);
|
||||
updateQueueSize();
|
||||
logger.log('info', `Email queued: ${envelope.from} → ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
|
||||
});
|
||||
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemDelivered', (item: any) => {
|
||||
const envelope = getEnvelope(item);
|
||||
this.metricsManager!.trackEmailSent(envelope.recipients[0]);
|
||||
updateQueueSize();
|
||||
logger.log('info', `Email delivered to ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
|
||||
});
|
||||
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemFailed', (item: any) => {
|
||||
const envelope = getEnvelope(item);
|
||||
this.metricsManager!.trackEmailFailed(envelope.recipients[0], item?.lastError);
|
||||
updateQueueSize();
|
||||
logger.log('warn', `Email delivery failed to ${envelope.recipients.join(', ') || 'unknown'}: ${item?.lastError || 'unknown error'}`, { zone: 'email' });
|
||||
});
|
||||
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemDeferred', () => {
|
||||
updateQueueSize();
|
||||
});
|
||||
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemRemoved', () => {
|
||||
updateQueueSize();
|
||||
});
|
||||
this.addEmailEventSubscription(this.emailServer, 'bounceProcessed', () => {
|
||||
this.metricsManager!.trackEmailBounced();
|
||||
logger.log('warn', 'Email bounce processed', { zone: 'email' });
|
||||
});
|
||||
updateQueueSize();
|
||||
}
|
||||
|
||||
logger.log('info', `Email server started on ports: ${emailConfig.ports.join(', ')}`);
|
||||
@@ -1574,11 +1616,7 @@ export class DcRouter {
|
||||
try {
|
||||
// Stop the unified email server which contains all components
|
||||
if (this.emailServer) {
|
||||
// Remove listeners before stopping to prevent leaks on config update cycles
|
||||
if ((this.emailServer as any).deliverySystem) {
|
||||
(this.emailServer as any).deliverySystem.removeAllListeners();
|
||||
}
|
||||
this.emailServer.removeAllListeners();
|
||||
this.clearEmailEventSubscriptions();
|
||||
await this.emailServer.stop();
|
||||
logger.log('info', 'Unified email server stopped');
|
||||
this.emailServer = undefined;
|
||||
@@ -1783,14 +1821,14 @@ export class DcRouter {
|
||||
// Generate and register authoritative records
|
||||
const authoritativeRecords = await this.generateAuthoritativeRecords();
|
||||
|
||||
// Generate email DNS records
|
||||
const emailDnsRecords = await this.generateEmailDnsRecords();
|
||||
|
||||
// Initialize DKIM for all email domains
|
||||
await this.initializeDkimForEmailDomains();
|
||||
|
||||
// Load DKIM records from JSON files (they should now exist)
|
||||
const dkimRecords = await this.loadDkimRecords();
|
||||
// Generate email DNS records
|
||||
const emailDnsRecords = await this.generateEmailDnsRecords();
|
||||
|
||||
// Ensure DKIM keys exist for internal-dns domains before generating records.
|
||||
await this.initializeDkimForEmailDomains();
|
||||
|
||||
// Generate DKIM records directly from smartmta instead of scanning legacy JSON files.
|
||||
const dkimRecords = await this.loadDkimRecords();
|
||||
|
||||
// Combine all records: authoritative, email, DKIM, and user-defined
|
||||
const allRecords = [...authoritativeRecords, ...emailDnsRecords, ...dkimRecords];
|
||||
@@ -1939,54 +1977,30 @@ export class DcRouter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load DKIM records from JSON files
|
||||
* Reads all *.dkimrecord.json files from the DNS records directory
|
||||
* Generate DKIM DNS records for internal-dns domains from smartmta's selector-aware DKIM state.
|
||||
*/
|
||||
private async loadDkimRecords(): Promise<Array<{name: string; type: string; value: string; ttl?: number}>> {
|
||||
const records: Array<{name: string; type: string; value: string; ttl?: number}> = [];
|
||||
|
||||
try {
|
||||
// Ensure paths are imported
|
||||
const dnsDir = this.resolvedPaths.dnsRecordsDir;
|
||||
|
||||
// Check if directory exists
|
||||
if (!plugins.fs.existsSync(dnsDir)) {
|
||||
logger.log('debug', 'No DNS records directory found, skipping DKIM record loading');
|
||||
return records;
|
||||
if (!this.options.emailConfig?.domains || !this.emailServer?.dkimCreator) {
|
||||
return records;
|
||||
}
|
||||
|
||||
for (const domainConfig of this.options.emailConfig.domains) {
|
||||
if (domainConfig.dnsMode !== 'internal-dns') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read all files in the directory
|
||||
const files = plugins.fs.readdirSync(dnsDir);
|
||||
const dkimFiles = files.filter(f => f.endsWith('.dkimrecord.json'));
|
||||
|
||||
logger.log('info', `Found ${dkimFiles.length} DKIM record files`);
|
||||
|
||||
// Load each DKIM record
|
||||
for (const file of dkimFiles) {
|
||||
try {
|
||||
const filePath = plugins.path.join(dnsDir, file);
|
||||
const fileContent = plugins.fs.readFileSync(filePath, 'utf8');
|
||||
const dkimRecord = JSON.parse(fileContent);
|
||||
|
||||
// Validate record structure
|
||||
if (dkimRecord.name && dkimRecord.type === 'TXT' && dkimRecord.value) {
|
||||
records.push({
|
||||
name: dkimRecord.name,
|
||||
type: 'TXT',
|
||||
value: dkimRecord.value,
|
||||
ttl: 3600 // Standard DKIM TTL
|
||||
});
|
||||
|
||||
logger.log('info', `Loaded DKIM record for ${dkimRecord.name}`);
|
||||
} else {
|
||||
logger.log('warn', `Invalid DKIM record structure in ${file}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to load DKIM record from ${file}: ${(error as Error).message}`);
|
||||
}
|
||||
const selector = domainConfig.dkim?.selector || 'default';
|
||||
try {
|
||||
const dkimRecord = await this.emailServer.dkimCreator.getDNSRecordForDomain(domainConfig.domain, selector);
|
||||
records.push({
|
||||
name: dkimRecord.name,
|
||||
type: 'TXT',
|
||||
value: dkimRecord.value,
|
||||
ttl: domainConfig.dns?.internal?.ttl || 3600,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to generate DKIM record for ${domainConfig.domain}: ${(error as Error).message}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to load DKIM records: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return records;
|
||||
@@ -2013,12 +2027,17 @@ export class DcRouter {
|
||||
// Ensure necessary directories exist
|
||||
paths.ensureDataDirectories(this.resolvedPaths);
|
||||
|
||||
// Generate DKIM keys for each email domain
|
||||
// Generate DKIM keys for each internal-dns email domain using the configured selector.
|
||||
for (const domainConfig of this.options.emailConfig.domains) {
|
||||
if (domainConfig.dnsMode !== 'internal-dns') {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// Generate DKIM keys for all domains, regardless of DNS mode
|
||||
// This ensures keys are ready even if DNS mode changes later
|
||||
await dkimCreator.handleDKIMKeysForDomain(domainConfig.domain);
|
||||
await dkimCreator.handleDKIMKeysForSelector(
|
||||
domainConfig.domain,
|
||||
domainConfig.dkim?.selector || 'default',
|
||||
domainConfig.dkim?.keySize || 2048,
|
||||
);
|
||||
logger.log('info', `DKIM keys initialized for ${domainConfig.domain}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to initialize DKIM for ${domainConfig.domain}: ${(error as Error).message}`);
|
||||
@@ -2148,6 +2167,25 @@ export class DcRouter {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private addEmailEventSubscription(
|
||||
emitter: {
|
||||
on(eventName: string, listener: (...args: any[]) => void): void;
|
||||
off(eventName: string, listener: (...args: any[]) => void): void;
|
||||
},
|
||||
eventName: string,
|
||||
listener: (...args: any[]) => void,
|
||||
): void {
|
||||
emitter.on(eventName, listener);
|
||||
this.emailEventSubscriptions.push({ emitter, eventName, listener });
|
||||
}
|
||||
|
||||
private clearEmailEventSubscriptions(): void {
|
||||
for (const subscription of this.emailEventSubscriptions) {
|
||||
subscription.emitter.off(subscription.eventName, subscription.listener);
|
||||
}
|
||||
this.emailEventSubscriptions = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the server's public IP address
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { IEmailDomainConfig } from '@push.rocks/smartmta';
|
||||
import { logger } from '../logger.js';
|
||||
import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
|
||||
import { DomainDoc } from '../db/documents/classes.domain.doc.js';
|
||||
@@ -15,9 +16,12 @@ import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_i
|
||||
*/
|
||||
export class EmailDomainManager {
|
||||
private dcRouter: any; // DcRouter — avoids circular import
|
||||
private readonly baseEmailDomains: IEmailDomainConfig[];
|
||||
|
||||
constructor(dcRouterRef: any) {
|
||||
this.dcRouter = dcRouterRef;
|
||||
this.baseEmailDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
|
||||
.map((domainConfig) => JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
|
||||
}
|
||||
|
||||
private get dnsManager(): DnsManager | undefined {
|
||||
@@ -32,6 +36,12 @@ export class EmailDomainManager {
|
||||
return this.dcRouter.options?.emailConfig?.hostname || this.dcRouter.options?.tls?.domain || 'localhost';
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
await this.syncManagedDomainsToRuntime();
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -64,6 +74,9 @@ export class EmailDomainManager {
|
||||
const domainName = subdomain ? `${subdomain}.${baseDomain}` : baseDomain;
|
||||
|
||||
// Check for duplicates
|
||||
if (this.isDomainAlreadyConfigured(domainName)) {
|
||||
throw new Error(`Email domain already configured for ${domainName}`);
|
||||
}
|
||||
const existing = await EmailDomainDoc.findByDomain(domainName);
|
||||
if (existing) {
|
||||
throw new Error(`Email domain already exists for ${domainName}`);
|
||||
@@ -77,8 +90,8 @@ export class EmailDomainManager {
|
||||
let publicKey: string | undefined;
|
||||
if (this.dkimCreator) {
|
||||
try {
|
||||
await this.dkimCreator.handleDKIMKeysForDomain(domainName);
|
||||
const dnsRecord = await this.dkimCreator.getDNSRecordForSelector(domainName, selector);
|
||||
await this.dkimCreator.handleDKIMKeysForSelector(domainName, selector, keySize);
|
||||
const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domainName, selector);
|
||||
// Extract public key from the DNS record value
|
||||
const match = dnsRecord?.value?.match(/p=([A-Za-z0-9+/=]+)/);
|
||||
publicKey = match ? match[1] : undefined;
|
||||
@@ -110,6 +123,7 @@ export class EmailDomainManager {
|
||||
doc.createdAt = now;
|
||||
doc.updatedAt = now;
|
||||
await doc.save();
|
||||
await this.syncManagedDomainsToRuntime();
|
||||
|
||||
logger.log('info', `Email domain created: ${domainName}`);
|
||||
return this.docToInterface(doc);
|
||||
@@ -131,12 +145,14 @@ export class EmailDomainManager {
|
||||
if (changes.rateLimits !== undefined) doc.rateLimits = changes.rateLimits;
|
||||
doc.updatedAt = new Date().toISOString();
|
||||
await doc.save();
|
||||
await this.syncManagedDomainsToRuntime();
|
||||
}
|
||||
|
||||
public async deleteEmailDomain(id: string): Promise<void> {
|
||||
const doc = await EmailDomainDoc.findById(id);
|
||||
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||
await doc.delete();
|
||||
await this.syncManagedDomainsToRuntime();
|
||||
logger.log('info', `Email domain deleted: ${doc.domain}`);
|
||||
}
|
||||
|
||||
@@ -153,8 +169,17 @@ export class EmailDomainManager {
|
||||
|
||||
const domain = doc.domain;
|
||||
const selector = doc.dkim.selector;
|
||||
const publicKey = doc.dkim.publicKey || '';
|
||||
const hostname = this.emailHostname;
|
||||
let dkimValue = `v=DKIM1; h=sha256; k=rsa; p=${doc.dkim.publicKey || ''}`;
|
||||
|
||||
if (this.dkimCreator) {
|
||||
try {
|
||||
const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domain, selector);
|
||||
dkimValue = dnsRecord.value;
|
||||
} catch (err: unknown) {
|
||||
logger.log('warn', `Failed to load DKIM DNS record for ${domain}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const records: IEmailDnsRecord[] = [
|
||||
{
|
||||
@@ -172,7 +197,7 @@ export class EmailDomainManager {
|
||||
{
|
||||
type: 'TXT',
|
||||
name: `${selector}._domainkey.${domain}`,
|
||||
value: `v=DKIM1; h=sha256; k=rsa; p=${publicKey}`,
|
||||
value: dkimValue,
|
||||
status: doc.dnsStatus.dkim,
|
||||
},
|
||||
{
|
||||
@@ -207,17 +232,7 @@ export class EmailDomainManager {
|
||||
|
||||
for (const required of requiredRecords) {
|
||||
// Check if a matching record already exists
|
||||
const exists = existingRecords.some((r) => {
|
||||
if (required.type === 'MX') {
|
||||
return r.type === 'MX' && r.name.toLowerCase() === required.name.toLowerCase();
|
||||
}
|
||||
// For TXT records, match by name AND check value prefix (v=spf1, v=DKIM1, v=DMARC1)
|
||||
if (r.type !== 'TXT' || r.name.toLowerCase() !== required.name.toLowerCase()) return false;
|
||||
if (required.value.startsWith('v=spf1')) return r.value.includes('v=spf1');
|
||||
if (required.value.startsWith('v=DKIM1')) return r.value.includes('v=DKIM1');
|
||||
if (required.value.startsWith('v=DMARC1')) return r.value.includes('v=DMARC1');
|
||||
return false;
|
||||
});
|
||||
const exists = existingRecords.some((r) => this.recordMatchesRequired(r, required));
|
||||
|
||||
if (!exists) {
|
||||
try {
|
||||
@@ -259,16 +274,23 @@ export class EmailDomainManager {
|
||||
const resolver = new plugins.dns.promises.Resolver();
|
||||
|
||||
// MX check
|
||||
doc.dnsStatus.mx = await this.checkMx(resolver, domain);
|
||||
const requiredRecords = await this.getRequiredDnsRecords(id);
|
||||
|
||||
const mxRecord = requiredRecords.find((record) => record.type === 'MX');
|
||||
const spfRecord = requiredRecords.find((record) => record.name === domain && record.value.startsWith('v=spf1'));
|
||||
const dkimRecord = requiredRecords.find((record) => record.name === `${selector}._domainkey.${domain}`);
|
||||
const dmarcRecord = requiredRecords.find((record) => record.name === `_dmarc.${domain}`);
|
||||
|
||||
doc.dnsStatus.mx = await this.checkMx(resolver, domain, mxRecord?.value);
|
||||
|
||||
// SPF check
|
||||
doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, 'v=spf1');
|
||||
doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, spfRecord?.value);
|
||||
|
||||
// DKIM check
|
||||
doc.dnsStatus.dkim = await this.checkTxtRecord(resolver, `${selector}._domainkey.${domain}`, 'v=DKIM1');
|
||||
doc.dnsStatus.dkim = await this.checkTxtRecord(resolver, `${selector}._domainkey.${domain}`, dkimRecord?.value);
|
||||
|
||||
// DMARC check
|
||||
doc.dnsStatus.dmarc = await this.checkTxtRecord(resolver, `_dmarc.${domain}`, 'v=DMARC1');
|
||||
doc.dnsStatus.dmarc = await this.checkTxtRecord(resolver, `_dmarc.${domain}`, dmarcRecord?.value);
|
||||
|
||||
doc.dnsStatus.lastCheckedAt = new Date().toISOString();
|
||||
doc.updatedAt = new Date().toISOString();
|
||||
@@ -277,10 +299,28 @@ export class EmailDomainManager {
|
||||
return this.getRequiredDnsRecords(id);
|
||||
}
|
||||
|
||||
private async checkMx(resolver: plugins.dns.promises.Resolver, domain: string): Promise<TDnsRecordStatus> {
|
||||
private recordMatchesRequired(record: DnsRecordDoc, required: IEmailDnsRecord): boolean {
|
||||
if (record.type !== required.type || record.name.toLowerCase() !== required.name.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
return record.value.trim() === required.value.trim();
|
||||
}
|
||||
|
||||
private async checkMx(
|
||||
resolver: plugins.dns.promises.Resolver,
|
||||
domain: string,
|
||||
expectedValue?: string,
|
||||
): Promise<TDnsRecordStatus> {
|
||||
try {
|
||||
const records = await resolver.resolveMx(domain);
|
||||
return records && records.length > 0 ? 'valid' : 'missing';
|
||||
if (!records || records.length === 0) {
|
||||
return 'missing';
|
||||
}
|
||||
if (!expectedValue) {
|
||||
return 'valid';
|
||||
}
|
||||
const found = records.some((record) => `${record.priority} ${record.exchange}`.trim() === expectedValue.trim());
|
||||
return found ? 'valid' : 'invalid';
|
||||
} catch {
|
||||
return 'missing';
|
||||
}
|
||||
@@ -289,13 +329,19 @@ export class EmailDomainManager {
|
||||
private async checkTxtRecord(
|
||||
resolver: plugins.dns.promises.Resolver,
|
||||
name: string,
|
||||
prefix: string,
|
||||
expectedValue?: string,
|
||||
): Promise<TDnsRecordStatus> {
|
||||
try {
|
||||
const records = await resolver.resolveTxt(name);
|
||||
const flat = records.map((r) => r.join(''));
|
||||
const found = flat.some((r) => r.startsWith(prefix));
|
||||
return found ? 'valid' : 'missing';
|
||||
if (flat.length === 0) {
|
||||
return 'missing';
|
||||
}
|
||||
if (!expectedValue) {
|
||||
return 'valid';
|
||||
}
|
||||
const found = flat.some((record) => record.trim() === expectedValue.trim());
|
||||
return found ? 'valid' : 'invalid';
|
||||
} catch {
|
||||
return 'missing';
|
||||
}
|
||||
@@ -318,4 +364,63 @@ export class EmailDomainManager {
|
||||
updatedAt: doc.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private isDomainAlreadyConfigured(domainName: string): boolean {
|
||||
const configuredDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
|
||||
.map((domainConfig) => domainConfig.domain.toLowerCase());
|
||||
return configuredDomains.includes(domainName.toLowerCase());
|
||||
}
|
||||
|
||||
private async buildManagedDomainConfigs(): Promise<IEmailDomainConfig[]> {
|
||||
const docs = await EmailDomainDoc.findAll();
|
||||
const managedConfigs: IEmailDomainConfig[] = [];
|
||||
|
||||
for (const doc of docs) {
|
||||
const linkedDomain = await DomainDoc.findById(doc.linkedDomainId);
|
||||
if (!linkedDomain) {
|
||||
logger.log('warn', `Skipping managed email domain ${doc.domain}: linked domain missing`);
|
||||
continue;
|
||||
}
|
||||
|
||||
managedConfigs.push({
|
||||
domain: doc.domain,
|
||||
dnsMode: linkedDomain.source === 'dcrouter' ? 'internal-dns' : 'external-dns',
|
||||
dkim: {
|
||||
selector: doc.dkim.selector,
|
||||
keySize: doc.dkim.keySize,
|
||||
rotateKeys: doc.dkim.rotateKeys,
|
||||
rotationInterval: doc.dkim.rotationIntervalDays,
|
||||
},
|
||||
rateLimits: doc.rateLimits,
|
||||
});
|
||||
}
|
||||
|
||||
return managedConfigs;
|
||||
}
|
||||
|
||||
private async syncManagedDomainsToRuntime(): Promise<void> {
|
||||
if (!this.dcRouter.options?.emailConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mergedDomains = new Map<string, IEmailDomainConfig>();
|
||||
for (const domainConfig of this.baseEmailDomains) {
|
||||
mergedDomains.set(domainConfig.domain.toLowerCase(), JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
|
||||
}
|
||||
|
||||
for (const managedConfig of await this.buildManagedDomainConfigs()) {
|
||||
const key = managedConfig.domain.toLowerCase();
|
||||
if (mergedDomains.has(key)) {
|
||||
logger.log('warn', `Managed email domain ${managedConfig.domain} duplicates a configured domain; keeping the configured definition`);
|
||||
continue;
|
||||
}
|
||||
mergedDomains.set(key, managedConfig);
|
||||
}
|
||||
|
||||
const domains = Array.from(mergedDomains.values());
|
||||
this.dcRouter.options.emailConfig.domains = domains;
|
||||
if (this.dcRouter.emailServer) {
|
||||
this.dcRouter.emailServer.updateOptions({ domains });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
108
ts/email/classes.smartmta-storage-manager.ts
Normal file
108
ts/email/classes.smartmta-storage-manager.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { IStorageManagerLike } from '@push.rocks/smartmta';
|
||||
|
||||
export class SmartMtaStorageManager implements IStorageManagerLike {
|
||||
private readonly resolvedRootDir: string;
|
||||
|
||||
constructor(private rootDir: string) {
|
||||
this.resolvedRootDir = plugins.path.resolve(rootDir);
|
||||
plugins.fsUtils.ensureDirSync(this.resolvedRootDir);
|
||||
}
|
||||
|
||||
private normalizeKey(key: string): string {
|
||||
return key.replace(/^\/+/, '').replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
private resolvePathForKey(key: string): string {
|
||||
const normalizedKey = this.normalizeKey(key);
|
||||
const resolvedPath = plugins.path.resolve(this.resolvedRootDir, normalizedKey);
|
||||
if (
|
||||
resolvedPath !== this.resolvedRootDir
|
||||
&& !resolvedPath.startsWith(`${this.resolvedRootDir}${plugins.path.sep}`)
|
||||
) {
|
||||
throw new Error(`Storage key escapes root directory: ${key}`);
|
||||
}
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
private toStorageKey(filePath: string): string {
|
||||
const relativePath = plugins.path.relative(this.resolvedRootDir, filePath).split(plugins.path.sep).join('/');
|
||||
return `/${relativePath}`;
|
||||
}
|
||||
|
||||
public async get(key: string): Promise<string | null> {
|
||||
const filePath = this.resolvePathForKey(key);
|
||||
try {
|
||||
return await plugins.fs.promises.readFile(filePath, 'utf8');
|
||||
} catch (error: unknown) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async set(key: string, value: string): Promise<void> {
|
||||
const filePath = this.resolvePathForKey(key);
|
||||
await plugins.fs.promises.mkdir(plugins.path.dirname(filePath), { recursive: true });
|
||||
await plugins.fs.promises.writeFile(filePath, value, 'utf8');
|
||||
}
|
||||
|
||||
public async list(prefix: string): Promise<string[]> {
|
||||
const prefixPath = this.resolvePathForKey(prefix);
|
||||
try {
|
||||
const stat = await plugins.fs.promises.stat(prefixPath);
|
||||
if (stat.isFile()) {
|
||||
return [this.toStorageKey(prefixPath)];
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const results: string[] = [];
|
||||
const walk = async (currentPath: string): Promise<void> => {
|
||||
const entries = await plugins.fs.promises.readdir(currentPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const entryPath = plugins.path.join(currentPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(entryPath);
|
||||
} else if (entry.isFile()) {
|
||||
results.push(this.toStorageKey(entryPath));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await walk(prefixPath);
|
||||
return results.sort();
|
||||
}
|
||||
|
||||
public async delete(key: string): Promise<void> {
|
||||
const targetPath = this.resolvePathForKey(key);
|
||||
try {
|
||||
const stat = await plugins.fs.promises.stat(targetPath);
|
||||
if (stat.isDirectory()) {
|
||||
await plugins.fs.promises.rm(targetPath, { recursive: true, force: true });
|
||||
} else {
|
||||
await plugins.fs.promises.unlink(targetPath);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let currentDir = plugins.path.dirname(targetPath);
|
||||
while (currentDir.startsWith(this.resolvedRootDir) && currentDir !== this.resolvedRootDir) {
|
||||
const entries = await plugins.fs.promises.readdir(currentDir);
|
||||
if (entries.length > 0) {
|
||||
break;
|
||||
}
|
||||
await plugins.fs.promises.rmdir(currentDir);
|
||||
currentDir = plugins.path.dirname(currentDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from './classes.email-domain.manager.js';
|
||||
export * from './classes.smartmta-storage-manager.js';
|
||||
|
||||
@@ -48,7 +48,7 @@ export class EmailOpsHandler {
|
||||
}
|
||||
|
||||
const queue = emailServer.deliveryQueue;
|
||||
const item = queue.getItem(dataArg.emailId);
|
||||
const item = emailServer.getQueueItem(dataArg.emailId);
|
||||
|
||||
if (!item) {
|
||||
return { success: false, error: 'Email not found in queue' };
|
||||
@@ -82,22 +82,10 @@ export class EmailOpsHandler {
|
||||
*/
|
||||
private getAllQueueEmails(): interfaces.requests.IEmail[] {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
if (!emailServer?.deliveryQueue) {
|
||||
if (!emailServer) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const queue = emailServer.deliveryQueue;
|
||||
const queueMap = (queue as any).queue as Map<string, any>;
|
||||
|
||||
if (!queueMap) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const emails: interfaces.requests.IEmail[] = [];
|
||||
|
||||
for (const [id, item] of queueMap.entries()) {
|
||||
emails.push(this.mapQueueItemToEmail(item));
|
||||
}
|
||||
const emails = emailServer.getQueueItems().map((item) => this.mapQueueItemToEmail(item));
|
||||
|
||||
// Sort by createdAt descending (newest first)
|
||||
emails.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
@@ -110,12 +98,10 @@ export class EmailOpsHandler {
|
||||
*/
|
||||
private getEmailDetail(emailId: string): interfaces.requests.IEmailDetail | null {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
if (!emailServer?.deliveryQueue) {
|
||||
if (!emailServer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const queue = emailServer.deliveryQueue;
|
||||
const item = queue.getItem(emailId);
|
||||
const item = emailServer.getQueueItem(emailId);
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
|
||||
@@ -530,13 +530,49 @@ export class StatsHandler {
|
||||
nextRetry?: number;
|
||||
}>;
|
||||
}> {
|
||||
// TODO: Implement actual queue status collection
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
if (!emailServer) {
|
||||
return {
|
||||
pending: 0,
|
||||
active: 0,
|
||||
failed: 0,
|
||||
retrying: 0,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
const queueStats = emailServer.getQueueStats();
|
||||
const items = emailServer.getQueueItems()
|
||||
.sort((a, b) => {
|
||||
const left = a.createdAt instanceof Date ? a.createdAt.getTime() : new Date(a.createdAt).getTime();
|
||||
const right = b.createdAt instanceof Date ? b.createdAt.getTime() : new Date(b.createdAt).getTime();
|
||||
return right - left;
|
||||
})
|
||||
.slice(0, 50)
|
||||
.map((item) => {
|
||||
const emailLike = item.processingResult;
|
||||
const recipients = Array.isArray(emailLike?.to)
|
||||
? emailLike.to
|
||||
: Array.isArray(emailLike?.email?.to)
|
||||
? emailLike.email.to
|
||||
: [];
|
||||
const subject = emailLike?.subject || emailLike?.email?.subject || '';
|
||||
return {
|
||||
id: item.id,
|
||||
recipient: recipients[0] || '',
|
||||
subject,
|
||||
status: item.status,
|
||||
attempts: item.attempts,
|
||||
nextRetry: item.nextAttempt instanceof Date ? item.nextAttempt.getTime() : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
pending: 0,
|
||||
active: 0,
|
||||
failed: 0,
|
||||
retrying: 0,
|
||||
items: [],
|
||||
pending: queueStats.status.pending,
|
||||
active: queueStats.status.processing,
|
||||
failed: queueStats.status.failed,
|
||||
retrying: queueStats.status.deferred,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -600,4 +636,4 @@ export class StatsHandler {
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user