feat(email): add persistent smartmta storage and runtime-managed email domain syncing
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# 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)
|
## 2026-04-14 - 13.17.9 - fix(monitoring)
|
||||||
align domain activity metrics with id-keyed route data
|
align domain activity metrics with id-keyed route data
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,8 @@
|
|||||||
"@git.zone/tsrun": "^2.0.2",
|
"@git.zone/tsrun": "^2.0.2",
|
||||||
"@git.zone/tstest": "^3.6.3",
|
"@git.zone/tstest": "^3.6.3",
|
||||||
"@git.zone/tswatch": "^3.3.2",
|
"@git.zone/tswatch": "^3.3.2",
|
||||||
"@types/node": "^25.6.0"
|
"@types/node": "^25.6.0",
|
||||||
|
"typescript": "^6.0.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@api.global/typedrequest": "^3.3.0",
|
"@api.global/typedrequest": "^3.3.0",
|
||||||
@@ -50,7 +51,7 @@
|
|||||||
"@push.rocks/smartlog": "^3.2.2",
|
"@push.rocks/smartlog": "^3.2.2",
|
||||||
"@push.rocks/smartmetrics": "^3.0.3",
|
"@push.rocks/smartmetrics": "^3.0.3",
|
||||||
"@push.rocks/smartmigration": "1.2.0",
|
"@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/smartnetwork": "^4.6.0",
|
||||||
"@push.rocks/smartpath": "^6.0.0",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"@push.rocks/smartpromise": "^4.2.3",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
|
|||||||
48
pnpm-lock.yaml
generated
48
pnpm-lock.yaml
generated
@@ -69,8 +69,8 @@ importers:
|
|||||||
specifier: 1.2.0
|
specifier: 1.2.0
|
||||||
version: 1.2.0(@push.rocks/smartbucket@4.6.0)(@push.rocks/smartdata@7.1.7(socks@2.8.7))
|
version: 1.2.0(@push.rocks/smartbucket@4.6.0)(@push.rocks/smartdata@7.1.7(socks@2.8.7))
|
||||||
'@push.rocks/smartmta':
|
'@push.rocks/smartmta':
|
||||||
specifier: ^5.3.1
|
specifier: ^5.3.3
|
||||||
version: 5.3.1
|
version: 5.3.3
|
||||||
'@push.rocks/smartnetwork':
|
'@push.rocks/smartnetwork':
|
||||||
specifier: ^4.6.0
|
specifier: ^4.6.0
|
||||||
version: 4.6.0
|
version: 4.6.0
|
||||||
@@ -147,6 +147,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^25.6.0
|
specifier: ^25.6.0
|
||||||
version: 25.6.0
|
version: 25.6.0
|
||||||
|
typescript:
|
||||||
|
specifier: ^6.0.2
|
||||||
|
version: 6.0.2
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -1248,8 +1251,8 @@ packages:
|
|||||||
'@push.rocks/smartmongo@5.1.1':
|
'@push.rocks/smartmongo@5.1.1':
|
||||||
resolution: {integrity: sha512-OFzEjTlXQ0zN9KYewhJRJxxX8bdVO7sl5H4RRd0F0PyU4FEXesLF8Sm4rsCFtQW1ifGQEBOcoruRkoiWz918Ug==}
|
resolution: {integrity: sha512-OFzEjTlXQ0zN9KYewhJRJxxX8bdVO7sl5H4RRd0F0PyU4FEXesLF8Sm4rsCFtQW1ifGQEBOcoruRkoiWz918Ug==}
|
||||||
|
|
||||||
'@push.rocks/smartmta@5.3.1':
|
'@push.rocks/smartmta@5.3.3':
|
||||||
resolution: {integrity: sha512-cEuXO56i/zL9eZS79eAesEW16ikdBJKLlEv9pLKkt2cmaHBWADGHjeOzJmsszQ9CSFcuhd41aHYVGMZXVvsG2g==}
|
resolution: {integrity: sha512-QxNob2yosDOhHMMjfUiQHfx8z+/UQQUdZY4ECATg3/xAMwnychR41IEVp6h7Qz3RjoJqS3NjRBThm9/jT02Gxg==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
cpu: [x64, arm64]
|
cpu: [x64, arm64]
|
||||||
os: [darwin, linux, win32]
|
os: [darwin, linux, win32]
|
||||||
@@ -3020,6 +3023,9 @@ packages:
|
|||||||
libmime@5.3.7:
|
libmime@5.3.7:
|
||||||
resolution: {integrity: sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==}
|
resolution: {integrity: sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==}
|
||||||
|
|
||||||
|
libmime@5.3.8:
|
||||||
|
resolution: {integrity: sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==}
|
||||||
|
|
||||||
libqp@2.1.1:
|
libqp@2.1.1:
|
||||||
resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==}
|
resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==}
|
||||||
|
|
||||||
@@ -3082,6 +3088,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==}
|
resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
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:
|
lru-cache@11.3.5:
|
||||||
resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==}
|
resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
@@ -3093,8 +3102,8 @@ packages:
|
|||||||
lucide@1.8.0:
|
lucide@1.8.0:
|
||||||
resolution: {integrity: sha512-JjV/QnadgFLj1Pyu9IKl0lknrolFEzo04B64QcYLLeRzZl/iEHpdbSrRRKbyXcv45SZNv+WGjIUCT33e7xHO6Q==}
|
resolution: {integrity: sha512-JjV/QnadgFLj1Pyu9IKl0lknrolFEzo04B64QcYLLeRzZl/iEHpdbSrRRKbyXcv45SZNv+WGjIUCT33e7xHO6Q==}
|
||||||
|
|
||||||
mailparser@3.9.6:
|
mailparser@3.9.8:
|
||||||
resolution: {integrity: sha512-EJYTDWMrOS1kddK1mTsRkrx2Ngh2nYsg54SRMWVVWGVEGbHH4tod8tqqU9hIRPgGQVboSjFubDn9cboSitbM3Q==}
|
resolution: {integrity: sha512-7jSlFGXiianVnhnb6wdutJFloD34488nrHY7r6FNqwXAhZ7YiJDYrKKTxZJ0oSrXcAPHm8YoYnh97xyGtrBQ3w==}
|
||||||
|
|
||||||
make-dir@3.1.0:
|
make-dir@3.1.0:
|
||||||
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
|
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
|
||||||
@@ -3431,8 +3440,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==}
|
resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==}
|
||||||
engines: {node: '>= 6.13.0'}
|
engines: {node: '>= 6.13.0'}
|
||||||
|
|
||||||
nodemailer@8.0.4:
|
nodemailer@8.0.5:
|
||||||
resolution: {integrity: sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==}
|
resolution: {integrity: sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
normalize-newline@4.1.0:
|
normalize-newline@4.1.0:
|
||||||
@@ -6405,7 +6414,7 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
'@push.rocks/smartmta@5.3.1':
|
'@push.rocks/smartmta@5.3.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/smartfile': 13.1.2
|
'@push.rocks/smartfile': 13.1.2
|
||||||
'@push.rocks/smartfs': 1.5.0
|
'@push.rocks/smartfs': 1.5.0
|
||||||
@@ -6414,8 +6423,8 @@ snapshots:
|
|||||||
'@push.rocks/smartpath': 6.0.0
|
'@push.rocks/smartpath': 6.0.0
|
||||||
'@push.rocks/smartrust': 1.3.2
|
'@push.rocks/smartrust': 1.3.2
|
||||||
'@tsclass/tsclass': 9.5.0
|
'@tsclass/tsclass': 9.5.0
|
||||||
lru-cache: 11.3.5
|
lru-cache: 10.4.3
|
||||||
mailparser: 3.9.6
|
mailparser: 3.9.8
|
||||||
uuid: 13.0.0
|
uuid: 13.0.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -8588,6 +8597,13 @@ snapshots:
|
|||||||
libbase64: 1.3.0
|
libbase64: 1.3.0
|
||||||
libqp: 2.1.1
|
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: {}
|
libqp@2.1.1: {}
|
||||||
|
|
||||||
lightweight-charts@5.1.0:
|
lightweight-charts@5.1.0:
|
||||||
@@ -8644,22 +8660,24 @@ snapshots:
|
|||||||
|
|
||||||
lowercase-keys@3.0.0: {}
|
lowercase-keys@3.0.0: {}
|
||||||
|
|
||||||
|
lru-cache@10.4.3: {}
|
||||||
|
|
||||||
lru-cache@11.3.5: {}
|
lru-cache@11.3.5: {}
|
||||||
|
|
||||||
lru-cache@7.18.3: {}
|
lru-cache@7.18.3: {}
|
||||||
|
|
||||||
lucide@1.8.0: {}
|
lucide@1.8.0: {}
|
||||||
|
|
||||||
mailparser@3.9.6:
|
mailparser@3.9.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@zone-eu/mailsplit': 5.4.8
|
'@zone-eu/mailsplit': 5.4.8
|
||||||
encoding-japanese: 2.2.0
|
encoding-japanese: 2.2.0
|
||||||
he: 1.2.0
|
he: 1.2.0
|
||||||
html-to-text: 9.0.5
|
html-to-text: 9.0.5
|
||||||
iconv-lite: 0.7.2
|
iconv-lite: 0.7.2
|
||||||
libmime: 5.3.7
|
libmime: 5.3.8
|
||||||
linkify-it: 5.0.0
|
linkify-it: 5.0.0
|
||||||
nodemailer: 8.0.4
|
nodemailer: 8.0.5
|
||||||
punycode.js: 2.3.1
|
punycode.js: 2.3.1
|
||||||
tlds: 1.261.0
|
tlds: 1.261.0
|
||||||
|
|
||||||
@@ -9164,7 +9182,7 @@ snapshots:
|
|||||||
|
|
||||||
node-forge@1.4.0: {}
|
node-forge@1.4.0: {}
|
||||||
|
|
||||||
nodemailer@8.0.4: {}
|
nodemailer@8.0.5: {}
|
||||||
|
|
||||||
normalize-newline@4.1.0:
|
normalize-newline@4.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
|
|||||||
|
|
||||||
// Verify unified email server was initialized
|
// Verify unified email server was initialized
|
||||||
expect(router.emailServer).toBeTruthy();
|
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
|
// Stop the router
|
||||||
await router.stop();
|
await router.stop();
|
||||||
|
|||||||
193
test/test.email-domain-manager.node.ts
Normal file
193
test/test.email-domain-manager.node.ts
Normal file
@@ -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();
|
||||||
31
test/test.smartmta-storage-manager.node.ts
Normal file
31
test/test.smartmta-storage-manager.node.ts
Normal file
@@ -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();
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.17.9',
|
version: '13.18.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type IUnifiedEmailServerOptions,
|
type IUnifiedEmailServerOptions,
|
||||||
type IEmailRoute,
|
type IEmailRoute,
|
||||||
type IEmailDomainConfig,
|
type IEmailDomainConfig,
|
||||||
|
type IStorageManagerLike,
|
||||||
} from '@push.rocks/smartmta';
|
} from '@push.rocks/smartmta';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { StorageBackedCertManager } from './classes.storage-cert-manager.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 { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
||||||
import { DnsManager } from './dns/manager.dns.js';
|
import { DnsManager } from './dns/manager.dns.js';
|
||||||
import { AcmeConfigManager } from './acme/manager.acme-config.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 {
|
export interface IDcRouterOptions {
|
||||||
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
||||||
@@ -248,15 +249,13 @@ export class DcRouter {
|
|||||||
public radiusServer?: RadiusServer;
|
public radiusServer?: RadiusServer;
|
||||||
public opsServer!: OpsServer;
|
public opsServer!: OpsServer;
|
||||||
public metricsManager?: MetricsManager;
|
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: IStorageManagerLike;
|
||||||
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}`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Unified database (smartdata + LocalSmartDb or external MongoDB)
|
// Unified database (smartdata + LocalSmartDb or external MongoDB)
|
||||||
public dcRouterDb?: DcRouterDb;
|
public dcRouterDb?: DcRouterDb;
|
||||||
@@ -329,6 +328,10 @@ export class DcRouter {
|
|||||||
|
|
||||||
// Resolve all data paths from baseDir
|
// Resolve all data paths from baseDir
|
||||||
this.resolvedPaths = paths.resolvePaths(this.options.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
|
// Initialize service manager and register all services
|
||||||
this.serviceManager = new plugins.taskbuffer.ServiceManager({
|
this.serviceManager = new plugins.taskbuffer.ServiceManager({
|
||||||
@@ -452,9 +455,13 @@ export class DcRouter {
|
|||||||
.dependsOn('DcRouterDb')
|
.dependsOn('DcRouterDb')
|
||||||
.withStart(async () => {
|
.withStart(async () => {
|
||||||
this.emailDomainManager = new EmailDomainManager(this);
|
this.emailDomainManager = new EmailDomainManager(this);
|
||||||
|
await this.emailDomainManager.start();
|
||||||
})
|
})
|
||||||
.withStop(async () => {
|
.withStop(async () => {
|
||||||
|
if (this.emailDomainManager) {
|
||||||
|
await this.emailDomainManager.stop();
|
||||||
this.emailDomainManager = undefined;
|
this.emailDomainManager = undefined;
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -610,19 +617,20 @@ export class DcRouter {
|
|||||||
|
|
||||||
// Email Server: optional, depends on SmartProxy
|
// Email Server: optional, depends on SmartProxy
|
||||||
if (this.options.emailConfig) {
|
if (this.options.emailConfig) {
|
||||||
|
const emailServiceDeps = ['SmartProxy', 'MetricsManager'];
|
||||||
|
if (this.options.dbConfig?.enabled !== false) {
|
||||||
|
emailServiceDeps.push('EmailDomainManager');
|
||||||
|
}
|
||||||
this.serviceManager.addService(
|
this.serviceManager.addService(
|
||||||
new plugins.taskbuffer.Service('EmailServer')
|
new plugins.taskbuffer.Service('EmailServer')
|
||||||
.optional()
|
.optional()
|
||||||
.dependsOn('SmartProxy')
|
.dependsOn(...emailServiceDeps)
|
||||||
.withStart(async () => {
|
.withStart(async () => {
|
||||||
await this.setupUnifiedEmailHandling();
|
await this.setupUnifiedEmailHandling();
|
||||||
})
|
})
|
||||||
.withStop(async () => {
|
.withStop(async () => {
|
||||||
if (this.emailServer) {
|
if (this.emailServer) {
|
||||||
if ((this.emailServer as any).deliverySystem) {
|
this.clearEmailEventSubscriptions();
|
||||||
(this.emailServer as any).deliverySystem.removeAllListeners();
|
|
||||||
}
|
|
||||||
this.emailServer.removeAllListeners();
|
|
||||||
await this.emailServer.stop();
|
await this.emailServer.stop();
|
||||||
this.emailServer = undefined;
|
this.emailServer = undefined;
|
||||||
}
|
}
|
||||||
@@ -636,7 +644,7 @@ export class DcRouter {
|
|||||||
this.serviceManager.addService(
|
this.serviceManager.addService(
|
||||||
new plugins.taskbuffer.Service('DnsServer')
|
new plugins.taskbuffer.Service('DnsServer')
|
||||||
.optional()
|
.optional()
|
||||||
.dependsOn('SmartProxy')
|
.dependsOn('SmartProxy', ...(this.options.emailConfig ? ['EmailServer'] : []))
|
||||||
.withStart(async () => {
|
.withStart(async () => {
|
||||||
await this.setupDnsWithSocketHandler();
|
await this.setupDnsWithSocketHandler();
|
||||||
})
|
})
|
||||||
@@ -1511,40 +1519,74 @@ export class DcRouter {
|
|||||||
...this.options.emailConfig,
|
...this.options.emailConfig,
|
||||||
domains: transformedDomains,
|
domains: transformedDomains,
|
||||||
ports: this.options.emailConfig.ports.map(port => portMapping[port] || port + 10000),
|
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
|
// Create unified email server
|
||||||
this.emailServer = new UnifiedEmailServer(this, emailConfig);
|
this.emailServer = new UnifiedEmailServer(this, emailConfig);
|
||||||
|
this.clearEmailEventSubscriptions();
|
||||||
|
|
||||||
// Set up error handling
|
// Set up error handling
|
||||||
this.emailServer.on('error', (err: Error) => {
|
this.addEmailEventSubscription(this.emailServer, 'error', (err: Error) => {
|
||||||
logger.log('error', `UnifiedEmailServer error: ${err.message}`);
|
logger.log('error', `UnifiedEmailServer error: ${err.message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start the server
|
// Start the server
|
||||||
await this.emailServer.start();
|
await this.emailServer.start();
|
||||||
|
|
||||||
// Wire delivery events to MetricsManager and logger
|
// Wire delivery events to MetricsManager and logger using smartmta's public queue APIs.
|
||||||
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' });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (this.metricsManager && this.emailServer) {
|
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();
|
this.metricsManager!.trackEmailBounced();
|
||||||
logger.log('warn', 'Email bounce processed', { zone: 'email' });
|
logger.log('warn', 'Email bounce processed', { zone: 'email' });
|
||||||
});
|
});
|
||||||
|
updateQueueSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log('info', `Email server started on ports: ${emailConfig.ports.join(', ')}`);
|
logger.log('info', `Email server started on ports: ${emailConfig.ports.join(', ')}`);
|
||||||
@@ -1574,11 +1616,7 @@ export class DcRouter {
|
|||||||
try {
|
try {
|
||||||
// Stop the unified email server which contains all components
|
// Stop the unified email server which contains all components
|
||||||
if (this.emailServer) {
|
if (this.emailServer) {
|
||||||
// Remove listeners before stopping to prevent leaks on config update cycles
|
this.clearEmailEventSubscriptions();
|
||||||
if ((this.emailServer as any).deliverySystem) {
|
|
||||||
(this.emailServer as any).deliverySystem.removeAllListeners();
|
|
||||||
}
|
|
||||||
this.emailServer.removeAllListeners();
|
|
||||||
await this.emailServer.stop();
|
await this.emailServer.stop();
|
||||||
logger.log('info', 'Unified email server stopped');
|
logger.log('info', 'Unified email server stopped');
|
||||||
this.emailServer = undefined;
|
this.emailServer = undefined;
|
||||||
@@ -1786,10 +1824,10 @@ export class DcRouter {
|
|||||||
// Generate email DNS records
|
// Generate email DNS records
|
||||||
const emailDnsRecords = await this.generateEmailDnsRecords();
|
const emailDnsRecords = await this.generateEmailDnsRecords();
|
||||||
|
|
||||||
// Initialize DKIM for all email domains
|
// Ensure DKIM keys exist for internal-dns domains before generating records.
|
||||||
await this.initializeDkimForEmailDomains();
|
await this.initializeDkimForEmailDomains();
|
||||||
|
|
||||||
// Load DKIM records from JSON files (they should now exist)
|
// Generate DKIM records directly from smartmta instead of scanning legacy JSON files.
|
||||||
const dkimRecords = await this.loadDkimRecords();
|
const dkimRecords = await this.loadDkimRecords();
|
||||||
|
|
||||||
// Combine all records: authoritative, email, DKIM, and user-defined
|
// Combine all records: authoritative, email, DKIM, and user-defined
|
||||||
@@ -1939,55 +1977,31 @@ export class DcRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load DKIM records from JSON files
|
* Generate DKIM DNS records for internal-dns domains from smartmta's selector-aware DKIM state.
|
||||||
* Reads all *.dkimrecord.json files from the DNS records directory
|
|
||||||
*/
|
*/
|
||||||
private async loadDkimRecords(): Promise<Array<{name: string; type: string; value: string; ttl?: number}>> {
|
private async loadDkimRecords(): Promise<Array<{name: string; type: string; value: string; ttl?: number}>> {
|
||||||
const records: Array<{name: string; type: string; value: string; ttl?: number}> = [];
|
const records: Array<{name: string; type: string; value: string; ttl?: number}> = [];
|
||||||
|
if (!this.options.emailConfig?.domains || !this.emailServer?.dkimCreator) {
|
||||||
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;
|
return records;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read all files in the directory
|
for (const domainConfig of this.options.emailConfig.domains) {
|
||||||
const files = plugins.fs.readdirSync(dnsDir);
|
if (domainConfig.dnsMode !== 'internal-dns') {
|
||||||
const dkimFiles = files.filter(f => f.endsWith('.dkimrecord.json'));
|
continue;
|
||||||
|
}
|
||||||
logger.log('info', `Found ${dkimFiles.length} DKIM record files`);
|
const selector = domainConfig.dkim?.selector || 'default';
|
||||||
|
|
||||||
// Load each DKIM record
|
|
||||||
for (const file of dkimFiles) {
|
|
||||||
try {
|
try {
|
||||||
const filePath = plugins.path.join(dnsDir, file);
|
const dkimRecord = await this.emailServer.dkimCreator.getDNSRecordForDomain(domainConfig.domain, selector);
|
||||||
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({
|
records.push({
|
||||||
name: dkimRecord.name,
|
name: dkimRecord.name,
|
||||||
type: 'TXT',
|
type: 'TXT',
|
||||||
value: dkimRecord.value,
|
value: dkimRecord.value,
|
||||||
ttl: 3600 // Standard DKIM TTL
|
ttl: domainConfig.dns?.internal?.ttl || 3600,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.log('info', `Loaded DKIM record for ${dkimRecord.name}`);
|
|
||||||
} else {
|
|
||||||
logger.log('warn', `Invalid DKIM record structure in ${file}`);
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Failed to load DKIM record from ${file}: ${(error as Error).message}`);
|
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;
|
return records;
|
||||||
}
|
}
|
||||||
@@ -2013,12 +2027,17 @@ export class DcRouter {
|
|||||||
// Ensure necessary directories exist
|
// Ensure necessary directories exist
|
||||||
paths.ensureDataDirectories(this.resolvedPaths);
|
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) {
|
for (const domainConfig of this.options.emailConfig.domains) {
|
||||||
|
if (domainConfig.dnsMode !== 'internal-dns') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// Generate DKIM keys for all domains, regardless of DNS mode
|
await dkimCreator.handleDKIMKeysForSelector(
|
||||||
// This ensures keys are ready even if DNS mode changes later
|
domainConfig.domain,
|
||||||
await dkimCreator.handleDKIMKeysForDomain(domainConfig.domain);
|
domainConfig.dkim?.selector || 'default',
|
||||||
|
domainConfig.dkim?.keySize || 2048,
|
||||||
|
);
|
||||||
logger.log('info', `DKIM keys initialized for ${domainConfig.domain}`);
|
logger.log('info', `DKIM keys initialized for ${domainConfig.domain}`);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Failed to initialize DKIM for ${domainConfig.domain}: ${(error as Error).message}`);
|
logger.log('error', `Failed to initialize DKIM for ${domainConfig.domain}: ${(error as Error).message}`);
|
||||||
@@ -2149,6 +2168,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
|
* Detect the server's public IP address
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { IEmailDomainConfig } from '@push.rocks/smartmta';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
|
import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
|
||||||
import { DomainDoc } from '../db/documents/classes.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 {
|
export class EmailDomainManager {
|
||||||
private dcRouter: any; // DcRouter — avoids circular import
|
private dcRouter: any; // DcRouter — avoids circular import
|
||||||
|
private readonly baseEmailDomains: IEmailDomainConfig[];
|
||||||
|
|
||||||
constructor(dcRouterRef: any) {
|
constructor(dcRouterRef: any) {
|
||||||
this.dcRouter = dcRouterRef;
|
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 {
|
private get dnsManager(): DnsManager | undefined {
|
||||||
@@ -32,6 +36,12 @@ export class EmailDomainManager {
|
|||||||
return this.dcRouter.options?.emailConfig?.hostname || this.dcRouter.options?.tls?.domain || 'localhost';
|
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
|
// CRUD
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -64,6 +74,9 @@ export class EmailDomainManager {
|
|||||||
const domainName = subdomain ? `${subdomain}.${baseDomain}` : baseDomain;
|
const domainName = subdomain ? `${subdomain}.${baseDomain}` : baseDomain;
|
||||||
|
|
||||||
// Check for duplicates
|
// Check for duplicates
|
||||||
|
if (this.isDomainAlreadyConfigured(domainName)) {
|
||||||
|
throw new Error(`Email domain already configured for ${domainName}`);
|
||||||
|
}
|
||||||
const existing = await EmailDomainDoc.findByDomain(domainName);
|
const existing = await EmailDomainDoc.findByDomain(domainName);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new Error(`Email domain already exists for ${domainName}`);
|
throw new Error(`Email domain already exists for ${domainName}`);
|
||||||
@@ -77,8 +90,8 @@ export class EmailDomainManager {
|
|||||||
let publicKey: string | undefined;
|
let publicKey: string | undefined;
|
||||||
if (this.dkimCreator) {
|
if (this.dkimCreator) {
|
||||||
try {
|
try {
|
||||||
await this.dkimCreator.handleDKIMKeysForDomain(domainName);
|
await this.dkimCreator.handleDKIMKeysForSelector(domainName, selector, keySize);
|
||||||
const dnsRecord = await this.dkimCreator.getDNSRecordForSelector(domainName, selector);
|
const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domainName, selector);
|
||||||
// Extract public key from the DNS record value
|
// Extract public key from the DNS record value
|
||||||
const match = dnsRecord?.value?.match(/p=([A-Za-z0-9+/=]+)/);
|
const match = dnsRecord?.value?.match(/p=([A-Za-z0-9+/=]+)/);
|
||||||
publicKey = match ? match[1] : undefined;
|
publicKey = match ? match[1] : undefined;
|
||||||
@@ -110,6 +123,7 @@ export class EmailDomainManager {
|
|||||||
doc.createdAt = now;
|
doc.createdAt = now;
|
||||||
doc.updatedAt = now;
|
doc.updatedAt = now;
|
||||||
await doc.save();
|
await doc.save();
|
||||||
|
await this.syncManagedDomainsToRuntime();
|
||||||
|
|
||||||
logger.log('info', `Email domain created: ${domainName}`);
|
logger.log('info', `Email domain created: ${domainName}`);
|
||||||
return this.docToInterface(doc);
|
return this.docToInterface(doc);
|
||||||
@@ -131,12 +145,14 @@ export class EmailDomainManager {
|
|||||||
if (changes.rateLimits !== undefined) doc.rateLimits = changes.rateLimits;
|
if (changes.rateLimits !== undefined) doc.rateLimits = changes.rateLimits;
|
||||||
doc.updatedAt = new Date().toISOString();
|
doc.updatedAt = new Date().toISOString();
|
||||||
await doc.save();
|
await doc.save();
|
||||||
|
await this.syncManagedDomainsToRuntime();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteEmailDomain(id: string): Promise<void> {
|
public async deleteEmailDomain(id: string): Promise<void> {
|
||||||
const doc = await EmailDomainDoc.findById(id);
|
const doc = await EmailDomainDoc.findById(id);
|
||||||
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||||
await doc.delete();
|
await doc.delete();
|
||||||
|
await this.syncManagedDomainsToRuntime();
|
||||||
logger.log('info', `Email domain deleted: ${doc.domain}`);
|
logger.log('info', `Email domain deleted: ${doc.domain}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,8 +169,17 @@ export class EmailDomainManager {
|
|||||||
|
|
||||||
const domain = doc.domain;
|
const domain = doc.domain;
|
||||||
const selector = doc.dkim.selector;
|
const selector = doc.dkim.selector;
|
||||||
const publicKey = doc.dkim.publicKey || '';
|
|
||||||
const hostname = this.emailHostname;
|
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[] = [
|
const records: IEmailDnsRecord[] = [
|
||||||
{
|
{
|
||||||
@@ -172,7 +197,7 @@ export class EmailDomainManager {
|
|||||||
{
|
{
|
||||||
type: 'TXT',
|
type: 'TXT',
|
||||||
name: `${selector}._domainkey.${domain}`,
|
name: `${selector}._domainkey.${domain}`,
|
||||||
value: `v=DKIM1; h=sha256; k=rsa; p=${publicKey}`,
|
value: dkimValue,
|
||||||
status: doc.dnsStatus.dkim,
|
status: doc.dnsStatus.dkim,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -207,17 +232,7 @@ export class EmailDomainManager {
|
|||||||
|
|
||||||
for (const required of requiredRecords) {
|
for (const required of requiredRecords) {
|
||||||
// Check if a matching record already exists
|
// Check if a matching record already exists
|
||||||
const exists = existingRecords.some((r) => {
|
const exists = existingRecords.some((r) => this.recordMatchesRequired(r, required));
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
try {
|
try {
|
||||||
@@ -259,16 +274,23 @@ export class EmailDomainManager {
|
|||||||
const resolver = new plugins.dns.promises.Resolver();
|
const resolver = new plugins.dns.promises.Resolver();
|
||||||
|
|
||||||
// MX check
|
// 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
|
// SPF check
|
||||||
doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, 'v=spf1');
|
doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, spfRecord?.value);
|
||||||
|
|
||||||
// DKIM check
|
// 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
|
// 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.dnsStatus.lastCheckedAt = new Date().toISOString();
|
||||||
doc.updatedAt = new Date().toISOString();
|
doc.updatedAt = new Date().toISOString();
|
||||||
@@ -277,10 +299,28 @@ export class EmailDomainManager {
|
|||||||
return this.getRequiredDnsRecords(id);
|
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 {
|
try {
|
||||||
const records = await resolver.resolveMx(domain);
|
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 {
|
} catch {
|
||||||
return 'missing';
|
return 'missing';
|
||||||
}
|
}
|
||||||
@@ -289,13 +329,19 @@ export class EmailDomainManager {
|
|||||||
private async checkTxtRecord(
|
private async checkTxtRecord(
|
||||||
resolver: plugins.dns.promises.Resolver,
|
resolver: plugins.dns.promises.Resolver,
|
||||||
name: string,
|
name: string,
|
||||||
prefix: string,
|
expectedValue?: string,
|
||||||
): Promise<TDnsRecordStatus> {
|
): Promise<TDnsRecordStatus> {
|
||||||
try {
|
try {
|
||||||
const records = await resolver.resolveTxt(name);
|
const records = await resolver.resolveTxt(name);
|
||||||
const flat = records.map((r) => r.join(''));
|
const flat = records.map((r) => r.join(''));
|
||||||
const found = flat.some((r) => r.startsWith(prefix));
|
if (flat.length === 0) {
|
||||||
return found ? 'valid' : 'missing';
|
return 'missing';
|
||||||
|
}
|
||||||
|
if (!expectedValue) {
|
||||||
|
return 'valid';
|
||||||
|
}
|
||||||
|
const found = flat.some((record) => record.trim() === expectedValue.trim());
|
||||||
|
return found ? 'valid' : 'invalid';
|
||||||
} catch {
|
} catch {
|
||||||
return 'missing';
|
return 'missing';
|
||||||
}
|
}
|
||||||
@@ -318,4 +364,63 @@ export class EmailDomainManager {
|
|||||||
updatedAt: doc.updatedAt,
|
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.email-domain.manager.js';
|
||||||
|
export * from './classes.smartmta-storage-manager.js';
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export class EmailOpsHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const queue = emailServer.deliveryQueue;
|
const queue = emailServer.deliveryQueue;
|
||||||
const item = queue.getItem(dataArg.emailId);
|
const item = emailServer.getQueueItem(dataArg.emailId);
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return { success: false, error: 'Email not found in queue' };
|
return { success: false, error: 'Email not found in queue' };
|
||||||
@@ -82,22 +82,10 @@ export class EmailOpsHandler {
|
|||||||
*/
|
*/
|
||||||
private getAllQueueEmails(): interfaces.requests.IEmail[] {
|
private getAllQueueEmails(): interfaces.requests.IEmail[] {
|
||||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||||
if (!emailServer?.deliveryQueue) {
|
if (!emailServer) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
const emails = emailServer.getQueueItems().map((item) => this.mapQueueItemToEmail(item));
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by createdAt descending (newest first)
|
// Sort by createdAt descending (newest first)
|
||||||
emails.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
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 {
|
private getEmailDetail(emailId: string): interfaces.requests.IEmailDetail | null {
|
||||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||||
if (!emailServer?.deliveryQueue) {
|
if (!emailServer) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const item = emailServer.getQueueItem(emailId);
|
||||||
const queue = emailServer.deliveryQueue;
|
|
||||||
const item = queue.getItem(emailId);
|
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -530,7 +530,8 @@ export class StatsHandler {
|
|||||||
nextRetry?: number;
|
nextRetry?: number;
|
||||||
}>;
|
}>;
|
||||||
}> {
|
}> {
|
||||||
// TODO: Implement actual queue status collection
|
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||||
|
if (!emailServer) {
|
||||||
return {
|
return {
|
||||||
pending: 0,
|
pending: 0,
|
||||||
active: 0,
|
active: 0,
|
||||||
@@ -540,6 +541,41 @@ export class StatsHandler {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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: queueStats.status.pending,
|
||||||
|
active: queueStats.status.processing,
|
||||||
|
failed: queueStats.status.failed,
|
||||||
|
retrying: queueStats.status.deferred,
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async checkHealthStatus(): Promise<{
|
private async checkHealthStatus(): Promise<{
|
||||||
healthy: boolean;
|
healthy: boolean;
|
||||||
services: Array<{
|
services: Array<{
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.17.9',
|
version: '13.18.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user