From 1d7e5495fa46cfdbf8c6223648bdc88f9e4265d2 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 14 Apr 2026 13:11:48 +0000 Subject: [PATCH] feat(email): add persistent smartmta storage and runtime-managed email domain syncing --- changelog.md | 7 + package.json | 5 +- pnpm-lock.yaml | 48 ++-- test/test.dcrouter.email.ts | 3 + test/test.email-domain-manager.node.ts | 193 ++++++++++++++++ test/test.smartmta-storage-manager.node.ts | 31 +++ ts/00_commitinfo_data.ts | 2 +- ts/classes.dcrouter.ts | 226 +++++++++++-------- ts/email/classes.email-domain.manager.ts | 153 +++++++++++-- ts/email/classes.smartmta-storage-manager.ts | 108 +++++++++ ts/email/index.ts | 1 + ts/opsserver/handlers/email-ops.handler.ts | 24 +- ts/opsserver/handlers/stats.handler.ts | 50 +++- ts_web/00_commitinfo_data.ts | 2 +- 14 files changed, 690 insertions(+), 163 deletions(-) create mode 100644 test/test.email-domain-manager.node.ts create mode 100644 test/test.smartmta-storage-manager.node.ts create mode 100644 ts/email/classes.smartmta-storage-manager.ts diff --git a/changelog.md b/changelog.md index be348f0..3f6ffde 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-04-14 - 13.18.0 - feat(email) +add persistent smartmta storage and runtime-managed email domain syncing + +- replace the email storage shim with a filesystem-backed SmartMtaStorageManager for DKIM and queue persistence +- sync managed email domains from the database into runtime email config and update the active email server on create, update, delete, and restart +- switch email queue, metrics, ops, and DNS integrations to smartmta public APIs including persisted queue stats and DKIM record generation + ## 2026-04-14 - 13.17.9 - fix(monitoring) align domain activity metrics with id-keyed route data diff --git a/package.json b/package.json index 5b043ea..6467f8c 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "@git.zone/tsrun": "^2.0.2", "@git.zone/tstest": "^3.6.3", "@git.zone/tswatch": "^3.3.2", - "@types/node": "^25.6.0" + "@types/node": "^25.6.0", + "typescript": "^6.0.2" }, "dependencies": { "@api.global/typedrequest": "^3.3.0", @@ -50,7 +51,7 @@ "@push.rocks/smartlog": "^3.2.2", "@push.rocks/smartmetrics": "^3.0.3", "@push.rocks/smartmigration": "1.2.0", - "@push.rocks/smartmta": "^5.3.1", + "@push.rocks/smartmta": "^5.3.3", "@push.rocks/smartnetwork": "^4.6.0", "@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpromise": "^4.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd9defe..a3ab034 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,8 +69,8 @@ importers: specifier: 1.2.0 version: 1.2.0(@push.rocks/smartbucket@4.6.0)(@push.rocks/smartdata@7.1.7(socks@2.8.7)) '@push.rocks/smartmta': - specifier: ^5.3.1 - version: 5.3.1 + specifier: ^5.3.3 + version: 5.3.3 '@push.rocks/smartnetwork': specifier: ^4.6.0 version: 4.6.0 @@ -147,6 +147,9 @@ importers: '@types/node': specifier: ^25.6.0 version: 25.6.0 + typescript: + specifier: ^6.0.2 + version: 6.0.2 packages: @@ -1248,8 +1251,8 @@ packages: '@push.rocks/smartmongo@5.1.1': resolution: {integrity: sha512-OFzEjTlXQ0zN9KYewhJRJxxX8bdVO7sl5H4RRd0F0PyU4FEXesLF8Sm4rsCFtQW1ifGQEBOcoruRkoiWz918Ug==} - '@push.rocks/smartmta@5.3.1': - resolution: {integrity: sha512-cEuXO56i/zL9eZS79eAesEW16ikdBJKLlEv9pLKkt2cmaHBWADGHjeOzJmsszQ9CSFcuhd41aHYVGMZXVvsG2g==} + '@push.rocks/smartmta@5.3.3': + resolution: {integrity: sha512-QxNob2yosDOhHMMjfUiQHfx8z+/UQQUdZY4ECATg3/xAMwnychR41IEVp6h7Qz3RjoJqS3NjRBThm9/jT02Gxg==} engines: {node: '>=14.0.0'} cpu: [x64, arm64] os: [darwin, linux, win32] @@ -3020,6 +3023,9 @@ packages: libmime@5.3.7: resolution: {integrity: sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==} + libmime@5.3.8: + resolution: {integrity: sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==} + libqp@2.1.1: resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==} @@ -3082,6 +3088,9 @@ packages: resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.3.5: resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} engines: {node: 20 || >=22} @@ -3093,8 +3102,8 @@ packages: lucide@1.8.0: resolution: {integrity: sha512-JjV/QnadgFLj1Pyu9IKl0lknrolFEzo04B64QcYLLeRzZl/iEHpdbSrRRKbyXcv45SZNv+WGjIUCT33e7xHO6Q==} - mailparser@3.9.6: - resolution: {integrity: sha512-EJYTDWMrOS1kddK1mTsRkrx2Ngh2nYsg54SRMWVVWGVEGbHH4tod8tqqU9hIRPgGQVboSjFubDn9cboSitbM3Q==} + mailparser@3.9.8: + resolution: {integrity: sha512-7jSlFGXiianVnhnb6wdutJFloD34488nrHY7r6FNqwXAhZ7YiJDYrKKTxZJ0oSrXcAPHm8YoYnh97xyGtrBQ3w==} make-dir@3.1.0: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} @@ -3431,8 +3440,8 @@ packages: resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} engines: {node: '>= 6.13.0'} - nodemailer@8.0.4: - resolution: {integrity: sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==} + nodemailer@8.0.5: + resolution: {integrity: sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==} engines: {node: '>=6.0.0'} normalize-newline@4.1.0: @@ -6405,7 +6414,7 @@ snapshots: - supports-color - vue - '@push.rocks/smartmta@5.3.1': + '@push.rocks/smartmta@5.3.3': dependencies: '@push.rocks/smartfile': 13.1.2 '@push.rocks/smartfs': 1.5.0 @@ -6414,8 +6423,8 @@ snapshots: '@push.rocks/smartpath': 6.0.0 '@push.rocks/smartrust': 1.3.2 '@tsclass/tsclass': 9.5.0 - lru-cache: 11.3.5 - mailparser: 3.9.6 + lru-cache: 10.4.3 + mailparser: 3.9.8 uuid: 13.0.0 transitivePeerDependencies: - supports-color @@ -8588,6 +8597,13 @@ snapshots: libbase64: 1.3.0 libqp: 2.1.1 + libmime@5.3.8: + dependencies: + encoding-japanese: 2.2.0 + iconv-lite: 0.7.2 + libbase64: 1.3.0 + libqp: 2.1.1 + libqp@2.1.1: {} lightweight-charts@5.1.0: @@ -8644,22 +8660,24 @@ snapshots: lowercase-keys@3.0.0: {} + lru-cache@10.4.3: {} + lru-cache@11.3.5: {} lru-cache@7.18.3: {} lucide@1.8.0: {} - mailparser@3.9.6: + mailparser@3.9.8: dependencies: '@zone-eu/mailsplit': 5.4.8 encoding-japanese: 2.2.0 he: 1.2.0 html-to-text: 9.0.5 iconv-lite: 0.7.2 - libmime: 5.3.7 + libmime: 5.3.8 linkify-it: 5.0.0 - nodemailer: 8.0.4 + nodemailer: 8.0.5 punycode.js: 2.3.1 tlds: 1.261.0 @@ -9164,7 +9182,7 @@ snapshots: node-forge@1.4.0: {} - nodemailer@8.0.4: {} + nodemailer@8.0.5: {} normalize-newline@4.1.0: dependencies: diff --git a/test/test.dcrouter.email.ts b/test/test.dcrouter.email.ts index 6f82bb0..fc19d9a 100644 --- a/test/test.dcrouter.email.ts +++ b/test/test.dcrouter.email.ts @@ -143,6 +143,9 @@ tap.test('DcRouter class - Email config with domains and routes', async () => { // Verify unified email server was initialized expect(router.emailServer).toBeTruthy(); + expect((router.emailServer as any).options.hostname).toEqual('mail.example.com'); + expect((router.emailServer as any).options.persistRoutes).toEqual(false); + expect((router.emailServer as any).options.queue.storageType).toEqual('disk'); // Stop the router await router.stop(); diff --git a/test/test.email-domain-manager.node.ts b/test/test.email-domain-manager.node.ts new file mode 100644 index 0000000..a63e466 --- /dev/null +++ b/test/test.email-domain-manager.node.ts @@ -0,0 +1,193 @@ +import { tap, expect } from '@git.zone/tstest/tapbundle'; +import * as plugins from '../ts/plugins.js'; +import { EmailDomainManager } from '../ts/email/index.js'; +import { DcRouterDb, DomainDoc } from '../ts/db/index.js'; +import { EmailDomainDoc } from '../ts/db/documents/classes.email-domain.doc.js'; +import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta'; + +const createTestDb = async () => { + const storagePath = plugins.path.join( + plugins.os.tmpdir(), + `dcrouter-email-domain-manager-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + + DcRouterDb.resetInstance(); + const db = DcRouterDb.getInstance({ + storagePath, + dbName: `dcrouter-email-domain-${Date.now()}-${Math.random().toString(16).slice(2)}`, + }); + await db.start(); + await db.getDb().mongoDb.createCollection('__test_init'); + + return { + async cleanup() { + await db.stop(); + DcRouterDb.resetInstance(); + await plugins.fs.promises.rm(storagePath, { recursive: true, force: true }); + }, + }; +}; + +const testDbPromise = createTestDb(); + +const clearTestState = async () => { + for (const emailDomain of await EmailDomainDoc.findAll()) { + await emailDomain.delete(); + } + for (const domain of await DomainDoc.findAll()) { + await domain.delete(); + } +}; + +const createDomainDoc = async (id: string, name: string, source: 'dcrouter' | 'provider') => { + const doc = new DomainDoc(); + doc.id = id; + doc.name = name; + doc.source = source; + doc.authoritative = source === 'dcrouter'; + doc.createdAt = Date.now(); + doc.updatedAt = Date.now(); + doc.createdBy = 'test'; + await doc.save(); + return doc; +}; + +const createBaseEmailConfig = (): IUnifiedEmailServerOptions => ({ + ports: [2525], + hostname: 'mail.example.com', + domains: [ + { + domain: 'static.example.com', + dnsMode: 'external-dns', + }, + ], + routes: [], +}); + +tap.test('EmailDomainManager syncs managed domains into runtime config and email server', async () => { + await testDbPromise; + await clearTestState(); + + const linkedDomain = await createDomainDoc('provider-domain', 'example.com', 'provider'); + const updateCalls: Array<{ domains?: any[] }> = []; + + const dcRouterStub = { + options: { + emailConfig: createBaseEmailConfig(), + }, + emailServer: { + updateOptions: (options: { domains?: any[] }) => { + updateCalls.push(options); + }, + }, + }; + + const manager = new EmailDomainManager(dcRouterStub); + await manager.start(); + + const created = await manager.createEmailDomain({ + linkedDomainId: linkedDomain.id, + subdomain: 'mail', + dkimSelector: 'selector1', + rotateKeys: true, + rotationIntervalDays: 30, + }); + + const domainsAfterCreate = dcRouterStub.options.emailConfig.domains; + expect(domainsAfterCreate.length).toEqual(2); + expect(domainsAfterCreate.some((domain) => domain.domain === 'static.example.com')).toEqual(true); + + const managedDomain = domainsAfterCreate.find((domain) => domain.domain === 'mail.example.com'); + expect(managedDomain).toBeTruthy(); + expect(managedDomain?.dnsMode).toEqual('external-dns'); + expect(managedDomain?.dkim?.selector).toEqual('selector1'); + expect(updateCalls.at(-1)?.domains?.some((domain) => domain.domain === 'mail.example.com')).toEqual(true); + + await manager.updateEmailDomain(created.id, { + rotateKeys: false, + rateLimits: { + outbound: { + messagesPerMinute: 10, + }, + }, + }); + + const domainsAfterUpdate = dcRouterStub.options.emailConfig.domains; + const updatedManagedDomain = domainsAfterUpdate.find((domain) => domain.domain === 'mail.example.com'); + expect(updatedManagedDomain?.dkim?.rotateKeys).toEqual(false); + expect(updatedManagedDomain?.rateLimits?.outbound?.messagesPerMinute).toEqual(10); + + await manager.deleteEmailDomain(created.id); + expect(dcRouterStub.options.emailConfig.domains.map((domain) => domain.domain)).toEqual(['static.example.com']); +}); + +tap.test('EmailDomainManager rejects domains already present in static config', async () => { + await testDbPromise; + await clearTestState(); + + const linkedDomain = await createDomainDoc('static-domain', 'static.example.com', 'provider'); + const dcRouterStub = { + options: { + emailConfig: createBaseEmailConfig(), + }, + }; + + const manager = new EmailDomainManager(dcRouterStub); + + let error: Error | undefined; + try { + await manager.createEmailDomain({ linkedDomainId: linkedDomain.id }); + } catch (err: unknown) { + error = err as Error; + } + + expect(error?.message).toEqual('Email domain already configured for static.example.com'); +}); + +tap.test('EmailDomainManager start merges persisted managed domains after restart', async () => { + await testDbPromise; + await clearTestState(); + + const linkedDomain = await createDomainDoc('local-domain', 'managed.example.com', 'dcrouter'); + const stored = new EmailDomainDoc(); + stored.id = 'managed-email-domain'; + stored.domain = 'mail.managed.example.com'; + stored.linkedDomainId = linkedDomain.id; + stored.subdomain = 'mail'; + stored.dkim = { + selector: 'default', + keySize: 2048, + rotateKeys: false, + rotationIntervalDays: 90, + }; + stored.dnsStatus = { + mx: 'unchecked', + spf: 'unchecked', + dkim: 'unchecked', + dmarc: 'unchecked', + }; + stored.createdAt = new Date().toISOString(); + stored.updatedAt = new Date().toISOString(); + await stored.save(); + + const dcRouterStub = { + options: { + emailConfig: createBaseEmailConfig(), + }, + }; + + const manager = new EmailDomainManager(dcRouterStub); + await manager.start(); + + const managedDomain = dcRouterStub.options.emailConfig.domains.find((domain) => domain.domain === 'mail.managed.example.com'); + expect(managedDomain?.dnsMode).toEqual('internal-dns'); +}); + +tap.test('cleanup', async () => { + const testDb = await testDbPromise; + await clearTestState(); + await testDb.cleanup(); + await tap.stopForcefully(); +}); + +export default tap.start(); diff --git a/test/test.smartmta-storage-manager.node.ts b/test/test.smartmta-storage-manager.node.ts new file mode 100644 index 0000000..c4800a5 --- /dev/null +++ b/test/test.smartmta-storage-manager.node.ts @@ -0,0 +1,31 @@ +import { tap, expect } from '@git.zone/tstest/tapbundle'; +import * as plugins from '../ts/plugins.js'; +import { SmartMtaStorageManager } from '../ts/email/index.js'; + +const tempDir = plugins.path.join(process.cwd(), '.nogit', 'test-smartmta-storage'); + +tap.test('SmartMtaStorageManager persists, lists, and deletes keys', async () => { + await plugins.fs.promises.rm(tempDir, { recursive: true, force: true }); + + const storageManager = new SmartMtaStorageManager(tempDir); + await storageManager.set('/email/dkim/example.com/default/metadata', 'metadata'); + await storageManager.set('/email/dkim/example.com/default/public.key', 'public'); + + expect(await storageManager.get('/email/dkim/example.com/default/metadata')).toEqual('metadata'); + + const keys = await storageManager.list('/email/dkim/example.com/'); + expect(keys).toEqual([ + '/email/dkim/example.com/default/metadata', + '/email/dkim/example.com/default/public.key', + ]); + + await storageManager.delete('/email/dkim/example.com/default/metadata'); + expect(await storageManager.get('/email/dkim/example.com/default/metadata')).toBeNull(); +}); + +tap.test('cleanup', async () => { + await plugins.fs.promises.rm(tempDir, { recursive: true, force: true }); + await tap.stopForcefully(); +}); + +export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 597917b..f9f1930 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -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.' } diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index 5e6e49c..cb43738 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -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> { 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 diff --git a/ts/email/classes.email-domain.manager.ts b/ts/email/classes.email-domain.manager.ts index 10881a4..1a2a0af 100644 --- a/ts/email/classes.email-domain.manager.ts +++ b/ts/email/classes.email-domain.manager.ts @@ -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 { + await this.syncManagedDomainsToRuntime(); + } + + public async stop(): Promise {} + // --------------------------------------------------------------------------- // 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 { 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 { + 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 { 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 { 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 { + 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 { + if (!this.dcRouter.options?.emailConfig) { + return; + } + + const mergedDomains = new Map(); + 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 }); + } + } } diff --git a/ts/email/classes.smartmta-storage-manager.ts b/ts/email/classes.smartmta-storage-manager.ts new file mode 100644 index 0000000..e3d4245 --- /dev/null +++ b/ts/email/classes.smartmta-storage-manager.ts @@ -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 { + 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 { + 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 { + 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 => { + 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 { + 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); + } + } +} diff --git a/ts/email/index.ts b/ts/email/index.ts index 4b65ee6..42f1f00 100644 --- a/ts/email/index.ts +++ b/ts/email/index.ts @@ -1 +1,2 @@ export * from './classes.email-domain.manager.js'; +export * from './classes.smartmta-storage-manager.js'; diff --git a/ts/opsserver/handlers/email-ops.handler.ts b/ts/opsserver/handlers/email-ops.handler.ts index c9c99c2..bc520da 100644 --- a/ts/opsserver/handlers/email-ops.handler.ts +++ b/ts/opsserver/handlers/email-ops.handler.ts @@ -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; - - 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; diff --git a/ts/opsserver/handlers/stats.handler.ts b/ts/opsserver/handlers/stats.handler.ts index bdaa524..40969ff 100644 --- a/ts/opsserver/handlers/stats.handler.ts +++ b/ts/opsserver/handlers/stats.handler.ts @@ -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 { ], }; } -} \ No newline at end of file +} diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 597917b..f9f1930 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -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.' }