@serve.zone/dcrouter 13.17.8 → 13.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_serve/bundle.js +2 -2
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.d.ts +7 -5
- package/dist_ts/classes.dcrouter.js +104 -83
- package/dist_ts/email/classes.email-domain.manager.d.ts +7 -0
- package/dist_ts/email/classes.email-domain.manager.js +111 -29
- package/dist_ts/email/classes.smartmta-storage-manager.d.ts +13 -0
- package/dist_ts/email/classes.smartmta-storage-manager.js +101 -0
- package/dist_ts/email/index.d.ts +1 -0
- package/dist_ts/email/index.js +2 -1
- package/dist_ts/monitoring/classes.metricsmanager.js +20 -19
- package/dist_ts/opsserver/handlers/email-ops.handler.js +6 -15
- package/dist_ts/opsserver/handlers/stats.handler.js +41 -7
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/elements/network/ops-view-network-activity.js +2 -2
- package/package.json +5 -4
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +130 -92
- package/ts/email/classes.email-domain.manager.ts +129 -24
- package/ts/email/classes.smartmta-storage-manager.ts +108 -0
- package/ts/email/index.ts +1 -0
- package/ts/monitoring/classes.metricsmanager.ts +20 -19
- package/ts/opsserver/handlers/email-ops.handler.ts +5 -19
- package/ts/opsserver/handlers/stats.handler.ts +43 -7
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/elements/network/ops-view-network-activity.ts +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as plugins from '../plugins.js';
|
|
2
|
+
import type { IEmailDomainConfig } from '@push.rocks/smartmta';
|
|
2
3
|
import { logger } from '../logger.js';
|
|
3
4
|
import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
|
|
4
5
|
import { DomainDoc } from '../db/documents/classes.domain.doc.js';
|
|
@@ -15,9 +16,12 @@ import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_i
|
|
|
15
16
|
*/
|
|
16
17
|
export class EmailDomainManager {
|
|
17
18
|
private dcRouter: any; // DcRouter — avoids circular import
|
|
19
|
+
private readonly baseEmailDomains: IEmailDomainConfig[];
|
|
18
20
|
|
|
19
21
|
constructor(dcRouterRef: any) {
|
|
20
22
|
this.dcRouter = dcRouterRef;
|
|
23
|
+
this.baseEmailDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
|
|
24
|
+
.map((domainConfig) => JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
private get dnsManager(): DnsManager | undefined {
|
|
@@ -32,6 +36,12 @@ export class EmailDomainManager {
|
|
|
32
36
|
return this.dcRouter.options?.emailConfig?.hostname || this.dcRouter.options?.tls?.domain || 'localhost';
|
|
33
37
|
}
|
|
34
38
|
|
|
39
|
+
public async start(): Promise<void> {
|
|
40
|
+
await this.syncManagedDomainsToRuntime();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public async stop(): Promise<void> {}
|
|
44
|
+
|
|
35
45
|
// ---------------------------------------------------------------------------
|
|
36
46
|
// CRUD
|
|
37
47
|
// ---------------------------------------------------------------------------
|
|
@@ -64,6 +74,9 @@ export class EmailDomainManager {
|
|
|
64
74
|
const domainName = subdomain ? `${subdomain}.${baseDomain}` : baseDomain;
|
|
65
75
|
|
|
66
76
|
// Check for duplicates
|
|
77
|
+
if (this.isDomainAlreadyConfigured(domainName)) {
|
|
78
|
+
throw new Error(`Email domain already configured for ${domainName}`);
|
|
79
|
+
}
|
|
67
80
|
const existing = await EmailDomainDoc.findByDomain(domainName);
|
|
68
81
|
if (existing) {
|
|
69
82
|
throw new Error(`Email domain already exists for ${domainName}`);
|
|
@@ -77,8 +90,8 @@ export class EmailDomainManager {
|
|
|
77
90
|
let publicKey: string | undefined;
|
|
78
91
|
if (this.dkimCreator) {
|
|
79
92
|
try {
|
|
80
|
-
await this.dkimCreator.
|
|
81
|
-
const dnsRecord = await this.dkimCreator.
|
|
93
|
+
await this.dkimCreator.handleDKIMKeysForSelector(domainName, selector, keySize);
|
|
94
|
+
const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domainName, selector);
|
|
82
95
|
// Extract public key from the DNS record value
|
|
83
96
|
const match = dnsRecord?.value?.match(/p=([A-Za-z0-9+/=]+)/);
|
|
84
97
|
publicKey = match ? match[1] : undefined;
|
|
@@ -110,6 +123,7 @@ export class EmailDomainManager {
|
|
|
110
123
|
doc.createdAt = now;
|
|
111
124
|
doc.updatedAt = now;
|
|
112
125
|
await doc.save();
|
|
126
|
+
await this.syncManagedDomainsToRuntime();
|
|
113
127
|
|
|
114
128
|
logger.log('info', `Email domain created: ${domainName}`);
|
|
115
129
|
return this.docToInterface(doc);
|
|
@@ -131,12 +145,14 @@ export class EmailDomainManager {
|
|
|
131
145
|
if (changes.rateLimits !== undefined) doc.rateLimits = changes.rateLimits;
|
|
132
146
|
doc.updatedAt = new Date().toISOString();
|
|
133
147
|
await doc.save();
|
|
148
|
+
await this.syncManagedDomainsToRuntime();
|
|
134
149
|
}
|
|
135
150
|
|
|
136
151
|
public async deleteEmailDomain(id: string): Promise<void> {
|
|
137
152
|
const doc = await EmailDomainDoc.findById(id);
|
|
138
153
|
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
|
139
154
|
await doc.delete();
|
|
155
|
+
await this.syncManagedDomainsToRuntime();
|
|
140
156
|
logger.log('info', `Email domain deleted: ${doc.domain}`);
|
|
141
157
|
}
|
|
142
158
|
|
|
@@ -153,8 +169,17 @@ export class EmailDomainManager {
|
|
|
153
169
|
|
|
154
170
|
const domain = doc.domain;
|
|
155
171
|
const selector = doc.dkim.selector;
|
|
156
|
-
const publicKey = doc.dkim.publicKey || '';
|
|
157
172
|
const hostname = this.emailHostname;
|
|
173
|
+
let dkimValue = `v=DKIM1; h=sha256; k=rsa; p=${doc.dkim.publicKey || ''}`;
|
|
174
|
+
|
|
175
|
+
if (this.dkimCreator) {
|
|
176
|
+
try {
|
|
177
|
+
const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domain, selector);
|
|
178
|
+
dkimValue = dnsRecord.value;
|
|
179
|
+
} catch (err: unknown) {
|
|
180
|
+
logger.log('warn', `Failed to load DKIM DNS record for ${domain}: ${(err as Error).message}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
158
183
|
|
|
159
184
|
const records: IEmailDnsRecord[] = [
|
|
160
185
|
{
|
|
@@ -172,7 +197,7 @@ export class EmailDomainManager {
|
|
|
172
197
|
{
|
|
173
198
|
type: 'TXT',
|
|
174
199
|
name: `${selector}._domainkey.${domain}`,
|
|
175
|
-
value:
|
|
200
|
+
value: dkimValue,
|
|
176
201
|
status: doc.dnsStatus.dkim,
|
|
177
202
|
},
|
|
178
203
|
{
|
|
@@ -207,17 +232,7 @@ export class EmailDomainManager {
|
|
|
207
232
|
|
|
208
233
|
for (const required of requiredRecords) {
|
|
209
234
|
// Check if a matching record already exists
|
|
210
|
-
const exists = existingRecords.some((r) =>
|
|
211
|
-
if (required.type === 'MX') {
|
|
212
|
-
return r.type === 'MX' && r.name.toLowerCase() === required.name.toLowerCase();
|
|
213
|
-
}
|
|
214
|
-
// For TXT records, match by name AND check value prefix (v=spf1, v=DKIM1, v=DMARC1)
|
|
215
|
-
if (r.type !== 'TXT' || r.name.toLowerCase() !== required.name.toLowerCase()) return false;
|
|
216
|
-
if (required.value.startsWith('v=spf1')) return r.value.includes('v=spf1');
|
|
217
|
-
if (required.value.startsWith('v=DKIM1')) return r.value.includes('v=DKIM1');
|
|
218
|
-
if (required.value.startsWith('v=DMARC1')) return r.value.includes('v=DMARC1');
|
|
219
|
-
return false;
|
|
220
|
-
});
|
|
235
|
+
const exists = existingRecords.some((r) => this.recordMatchesRequired(r, required));
|
|
221
236
|
|
|
222
237
|
if (!exists) {
|
|
223
238
|
try {
|
|
@@ -259,16 +274,23 @@ export class EmailDomainManager {
|
|
|
259
274
|
const resolver = new plugins.dns.promises.Resolver();
|
|
260
275
|
|
|
261
276
|
// MX check
|
|
262
|
-
|
|
277
|
+
const requiredRecords = await this.getRequiredDnsRecords(id);
|
|
278
|
+
|
|
279
|
+
const mxRecord = requiredRecords.find((record) => record.type === 'MX');
|
|
280
|
+
const spfRecord = requiredRecords.find((record) => record.name === domain && record.value.startsWith('v=spf1'));
|
|
281
|
+
const dkimRecord = requiredRecords.find((record) => record.name === `${selector}._domainkey.${domain}`);
|
|
282
|
+
const dmarcRecord = requiredRecords.find((record) => record.name === `_dmarc.${domain}`);
|
|
283
|
+
|
|
284
|
+
doc.dnsStatus.mx = await this.checkMx(resolver, domain, mxRecord?.value);
|
|
263
285
|
|
|
264
286
|
// SPF check
|
|
265
|
-
doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain,
|
|
287
|
+
doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, spfRecord?.value);
|
|
266
288
|
|
|
267
289
|
// DKIM check
|
|
268
|
-
doc.dnsStatus.dkim = await this.checkTxtRecord(resolver, `${selector}._domainkey.${domain}`,
|
|
290
|
+
doc.dnsStatus.dkim = await this.checkTxtRecord(resolver, `${selector}._domainkey.${domain}`, dkimRecord?.value);
|
|
269
291
|
|
|
270
292
|
// DMARC check
|
|
271
|
-
doc.dnsStatus.dmarc = await this.checkTxtRecord(resolver, `_dmarc.${domain}`,
|
|
293
|
+
doc.dnsStatus.dmarc = await this.checkTxtRecord(resolver, `_dmarc.${domain}`, dmarcRecord?.value);
|
|
272
294
|
|
|
273
295
|
doc.dnsStatus.lastCheckedAt = new Date().toISOString();
|
|
274
296
|
doc.updatedAt = new Date().toISOString();
|
|
@@ -277,10 +299,28 @@ export class EmailDomainManager {
|
|
|
277
299
|
return this.getRequiredDnsRecords(id);
|
|
278
300
|
}
|
|
279
301
|
|
|
280
|
-
private
|
|
302
|
+
private recordMatchesRequired(record: DnsRecordDoc, required: IEmailDnsRecord): boolean {
|
|
303
|
+
if (record.type !== required.type || record.name.toLowerCase() !== required.name.toLowerCase()) {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
return record.value.trim() === required.value.trim();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private async checkMx(
|
|
310
|
+
resolver: plugins.dns.promises.Resolver,
|
|
311
|
+
domain: string,
|
|
312
|
+
expectedValue?: string,
|
|
313
|
+
): Promise<TDnsRecordStatus> {
|
|
281
314
|
try {
|
|
282
315
|
const records = await resolver.resolveMx(domain);
|
|
283
|
-
|
|
316
|
+
if (!records || records.length === 0) {
|
|
317
|
+
return 'missing';
|
|
318
|
+
}
|
|
319
|
+
if (!expectedValue) {
|
|
320
|
+
return 'valid';
|
|
321
|
+
}
|
|
322
|
+
const found = records.some((record) => `${record.priority} ${record.exchange}`.trim() === expectedValue.trim());
|
|
323
|
+
return found ? 'valid' : 'invalid';
|
|
284
324
|
} catch {
|
|
285
325
|
return 'missing';
|
|
286
326
|
}
|
|
@@ -289,13 +329,19 @@ export class EmailDomainManager {
|
|
|
289
329
|
private async checkTxtRecord(
|
|
290
330
|
resolver: plugins.dns.promises.Resolver,
|
|
291
331
|
name: string,
|
|
292
|
-
|
|
332
|
+
expectedValue?: string,
|
|
293
333
|
): Promise<TDnsRecordStatus> {
|
|
294
334
|
try {
|
|
295
335
|
const records = await resolver.resolveTxt(name);
|
|
296
336
|
const flat = records.map((r) => r.join(''));
|
|
297
|
-
|
|
298
|
-
|
|
337
|
+
if (flat.length === 0) {
|
|
338
|
+
return 'missing';
|
|
339
|
+
}
|
|
340
|
+
if (!expectedValue) {
|
|
341
|
+
return 'valid';
|
|
342
|
+
}
|
|
343
|
+
const found = flat.some((record) => record.trim() === expectedValue.trim());
|
|
344
|
+
return found ? 'valid' : 'invalid';
|
|
299
345
|
} catch {
|
|
300
346
|
return 'missing';
|
|
301
347
|
}
|
|
@@ -318,4 +364,63 @@ export class EmailDomainManager {
|
|
|
318
364
|
updatedAt: doc.updatedAt,
|
|
319
365
|
};
|
|
320
366
|
}
|
|
367
|
+
|
|
368
|
+
private isDomainAlreadyConfigured(domainName: string): boolean {
|
|
369
|
+
const configuredDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
|
|
370
|
+
.map((domainConfig) => domainConfig.domain.toLowerCase());
|
|
371
|
+
return configuredDomains.includes(domainName.toLowerCase());
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private async buildManagedDomainConfigs(): Promise<IEmailDomainConfig[]> {
|
|
375
|
+
const docs = await EmailDomainDoc.findAll();
|
|
376
|
+
const managedConfigs: IEmailDomainConfig[] = [];
|
|
377
|
+
|
|
378
|
+
for (const doc of docs) {
|
|
379
|
+
const linkedDomain = await DomainDoc.findById(doc.linkedDomainId);
|
|
380
|
+
if (!linkedDomain) {
|
|
381
|
+
logger.log('warn', `Skipping managed email domain ${doc.domain}: linked domain missing`);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
managedConfigs.push({
|
|
386
|
+
domain: doc.domain,
|
|
387
|
+
dnsMode: linkedDomain.source === 'dcrouter' ? 'internal-dns' : 'external-dns',
|
|
388
|
+
dkim: {
|
|
389
|
+
selector: doc.dkim.selector,
|
|
390
|
+
keySize: doc.dkim.keySize,
|
|
391
|
+
rotateKeys: doc.dkim.rotateKeys,
|
|
392
|
+
rotationInterval: doc.dkim.rotationIntervalDays,
|
|
393
|
+
},
|
|
394
|
+
rateLimits: doc.rateLimits,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return managedConfigs;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private async syncManagedDomainsToRuntime(): Promise<void> {
|
|
402
|
+
if (!this.dcRouter.options?.emailConfig) {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const mergedDomains = new Map<string, IEmailDomainConfig>();
|
|
407
|
+
for (const domainConfig of this.baseEmailDomains) {
|
|
408
|
+
mergedDomains.set(domainConfig.domain.toLowerCase(), JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
for (const managedConfig of await this.buildManagedDomainConfigs()) {
|
|
412
|
+
const key = managedConfig.domain.toLowerCase();
|
|
413
|
+
if (mergedDomains.has(key)) {
|
|
414
|
+
logger.log('warn', `Managed email domain ${managedConfig.domain} duplicates a configured domain; keeping the configured definition`);
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
mergedDomains.set(key, managedConfig);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const domains = Array.from(mergedDomains.values());
|
|
421
|
+
this.dcRouter.options.emailConfig.domains = domains;
|
|
422
|
+
if (this.dcRouter.emailServer) {
|
|
423
|
+
this.dcRouter.emailServer.updateOptions({ domains });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
321
426
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import * as plugins from '../plugins.js';
|
|
2
|
+
import type { IStorageManagerLike } from '@push.rocks/smartmta';
|
|
3
|
+
|
|
4
|
+
export class SmartMtaStorageManager implements IStorageManagerLike {
|
|
5
|
+
private readonly resolvedRootDir: string;
|
|
6
|
+
|
|
7
|
+
constructor(private rootDir: string) {
|
|
8
|
+
this.resolvedRootDir = plugins.path.resolve(rootDir);
|
|
9
|
+
plugins.fsUtils.ensureDirSync(this.resolvedRootDir);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
private normalizeKey(key: string): string {
|
|
13
|
+
return key.replace(/^\/+/, '').replace(/\\/g, '/');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
private resolvePathForKey(key: string): string {
|
|
17
|
+
const normalizedKey = this.normalizeKey(key);
|
|
18
|
+
const resolvedPath = plugins.path.resolve(this.resolvedRootDir, normalizedKey);
|
|
19
|
+
if (
|
|
20
|
+
resolvedPath !== this.resolvedRootDir
|
|
21
|
+
&& !resolvedPath.startsWith(`${this.resolvedRootDir}${plugins.path.sep}`)
|
|
22
|
+
) {
|
|
23
|
+
throw new Error(`Storage key escapes root directory: ${key}`);
|
|
24
|
+
}
|
|
25
|
+
return resolvedPath;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private toStorageKey(filePath: string): string {
|
|
29
|
+
const relativePath = plugins.path.relative(this.resolvedRootDir, filePath).split(plugins.path.sep).join('/');
|
|
30
|
+
return `/${relativePath}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public async get(key: string): Promise<string | null> {
|
|
34
|
+
const filePath = this.resolvePathForKey(key);
|
|
35
|
+
try {
|
|
36
|
+
return await plugins.fs.promises.readFile(filePath, 'utf8');
|
|
37
|
+
} catch (error: unknown) {
|
|
38
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
public async set(key: string, value: string): Promise<void> {
|
|
46
|
+
const filePath = this.resolvePathForKey(key);
|
|
47
|
+
await plugins.fs.promises.mkdir(plugins.path.dirname(filePath), { recursive: true });
|
|
48
|
+
await plugins.fs.promises.writeFile(filePath, value, 'utf8');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public async list(prefix: string): Promise<string[]> {
|
|
52
|
+
const prefixPath = this.resolvePathForKey(prefix);
|
|
53
|
+
try {
|
|
54
|
+
const stat = await plugins.fs.promises.stat(prefixPath);
|
|
55
|
+
if (stat.isFile()) {
|
|
56
|
+
return [this.toStorageKey(prefixPath)];
|
|
57
|
+
}
|
|
58
|
+
} catch (error: unknown) {
|
|
59
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const results: string[] = [];
|
|
66
|
+
const walk = async (currentPath: string): Promise<void> => {
|
|
67
|
+
const entries = await plugins.fs.promises.readdir(currentPath, { withFileTypes: true });
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
const entryPath = plugins.path.join(currentPath, entry.name);
|
|
70
|
+
if (entry.isDirectory()) {
|
|
71
|
+
await walk(entryPath);
|
|
72
|
+
} else if (entry.isFile()) {
|
|
73
|
+
results.push(this.toStorageKey(entryPath));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
await walk(prefixPath);
|
|
79
|
+
return results.sort();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
public async delete(key: string): Promise<void> {
|
|
83
|
+
const targetPath = this.resolvePathForKey(key);
|
|
84
|
+
try {
|
|
85
|
+
const stat = await plugins.fs.promises.stat(targetPath);
|
|
86
|
+
if (stat.isDirectory()) {
|
|
87
|
+
await plugins.fs.promises.rm(targetPath, { recursive: true, force: true });
|
|
88
|
+
} else {
|
|
89
|
+
await plugins.fs.promises.unlink(targetPath);
|
|
90
|
+
}
|
|
91
|
+
} catch (error: unknown) {
|
|
92
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let currentDir = plugins.path.dirname(targetPath);
|
|
99
|
+
while (currentDir.startsWith(this.resolvedRootDir) && currentDir !== this.resolvedRootDir) {
|
|
100
|
+
const entries = await plugins.fs.promises.readdir(currentDir);
|
|
101
|
+
if (entries.length > 0) {
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
await plugins.fs.promises.rmdir(currentDir);
|
|
105
|
+
currentDir = plugins.path.dirname(currentDir);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
package/ts/email/index.ts
CHANGED
|
@@ -733,16 +733,17 @@ export class MetricsManager {
|
|
|
733
733
|
}
|
|
734
734
|
}
|
|
735
735
|
|
|
736
|
-
// Map route
|
|
736
|
+
// Map canonical route key → domains from route config
|
|
737
737
|
const routeDomains = new Map<string, string[]>();
|
|
738
738
|
if (this.dcRouter.smartProxy) {
|
|
739
739
|
for (const route of this.dcRouter.smartProxy.routeManager.getRoutes()) {
|
|
740
|
-
|
|
740
|
+
const routeKey = route.name || route.id;
|
|
741
|
+
if (!routeKey || !route.match.domains) continue;
|
|
741
742
|
const domains = Array.isArray(route.match.domains)
|
|
742
743
|
? route.match.domains
|
|
743
744
|
: [route.match.domains];
|
|
744
745
|
if (domains.length > 0) {
|
|
745
|
-
routeDomains.set(
|
|
746
|
+
routeDomains.set(routeKey, domains);
|
|
746
747
|
}
|
|
747
748
|
}
|
|
748
749
|
}
|
|
@@ -753,23 +754,23 @@ export class MetricsManager {
|
|
|
753
754
|
if (entry.domain) allKnownDomains.add(entry.domain);
|
|
754
755
|
}
|
|
755
756
|
|
|
756
|
-
// Build reverse map: concrete domain → route
|
|
757
|
+
// Build reverse map: concrete domain → canonical route key(s)
|
|
757
758
|
const domainToRoutes = new Map<string, string[]>();
|
|
758
|
-
for (const [
|
|
759
|
+
for (const [routeKey, domains] of routeDomains) {
|
|
759
760
|
for (const pattern of domains) {
|
|
760
761
|
if (pattern.includes('*')) {
|
|
761
762
|
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '[^.]+') + '$');
|
|
762
763
|
for (const knownDomain of allKnownDomains) {
|
|
763
764
|
if (regex.test(knownDomain)) {
|
|
764
765
|
const existing = domainToRoutes.get(knownDomain);
|
|
765
|
-
if (existing) { existing.push(
|
|
766
|
-
else { domainToRoutes.set(knownDomain, [
|
|
766
|
+
if (existing) { existing.push(routeKey); }
|
|
767
|
+
else { domainToRoutes.set(knownDomain, [routeKey]); }
|
|
767
768
|
}
|
|
768
769
|
}
|
|
769
770
|
} else {
|
|
770
771
|
const existing = domainToRoutes.get(pattern);
|
|
771
|
-
if (existing) { existing.push(
|
|
772
|
-
else { domainToRoutes.set(pattern, [
|
|
772
|
+
if (existing) { existing.push(routeKey); }
|
|
773
|
+
else { domainToRoutes.set(pattern, [routeKey]); }
|
|
773
774
|
}
|
|
774
775
|
}
|
|
775
776
|
}
|
|
@@ -777,10 +778,10 @@ export class MetricsManager {
|
|
|
777
778
|
// For each route, compute the total request count across all its resolved domains
|
|
778
779
|
// so we can distribute throughput/connections proportionally
|
|
779
780
|
const routeTotalRequests = new Map<string, number>();
|
|
780
|
-
for (const [domain,
|
|
781
|
+
for (const [domain, routeKeys] of domainToRoutes) {
|
|
781
782
|
const reqs = domainRequestTotals.get(domain) || 0;
|
|
782
|
-
for (const
|
|
783
|
-
routeTotalRequests.set(
|
|
783
|
+
for (const routeKey of routeKeys) {
|
|
784
|
+
routeTotalRequests.set(routeKey, (routeTotalRequests.get(routeKey) || 0) + reqs);
|
|
784
785
|
}
|
|
785
786
|
}
|
|
786
787
|
|
|
@@ -793,16 +794,16 @@ export class MetricsManager {
|
|
|
793
794
|
requestCount: number;
|
|
794
795
|
}>();
|
|
795
796
|
|
|
796
|
-
for (const [domain,
|
|
797
|
+
for (const [domain, routeKeys] of domainToRoutes) {
|
|
797
798
|
const domainReqs = domainRequestTotals.get(domain) || 0;
|
|
798
799
|
let totalConns = 0;
|
|
799
800
|
let totalIn = 0;
|
|
800
801
|
let totalOut = 0;
|
|
801
802
|
|
|
802
|
-
for (const
|
|
803
|
-
const conns = connectionsByRoute.get(
|
|
804
|
-
const tp = throughputByRoute.get(
|
|
805
|
-
const routeTotal = routeTotalRequests.get(
|
|
803
|
+
for (const routeKey of routeKeys) {
|
|
804
|
+
const conns = connectionsByRoute.get(routeKey) || 0;
|
|
805
|
+
const tp = throughputByRoute.get(routeKey) || { in: 0, out: 0 };
|
|
806
|
+
const routeTotal = routeTotalRequests.get(routeKey) || 0;
|
|
806
807
|
|
|
807
808
|
const share = routeTotal > 0 ? domainReqs / routeTotal : 0;
|
|
808
809
|
totalConns += conns * share;
|
|
@@ -814,7 +815,7 @@ export class MetricsManager {
|
|
|
814
815
|
activeConnections: Math.round(totalConns),
|
|
815
816
|
bytesInPerSec: totalIn,
|
|
816
817
|
bytesOutPerSec: totalOut,
|
|
817
|
-
routeCount:
|
|
818
|
+
routeCount: routeKeys.length,
|
|
818
819
|
requestCount: domainReqs,
|
|
819
820
|
});
|
|
820
821
|
}
|
|
@@ -990,4 +991,4 @@ export class MetricsManager {
|
|
|
990
991
|
|
|
991
992
|
return { queries };
|
|
992
993
|
}
|
|
993
|
-
}
|
|
994
|
+
}
|
|
@@ -48,7 +48,7 @@ export class EmailOpsHandler {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
const queue = emailServer.deliveryQueue;
|
|
51
|
-
const item =
|
|
51
|
+
const item = emailServer.getQueueItem(dataArg.emailId);
|
|
52
52
|
|
|
53
53
|
if (!item) {
|
|
54
54
|
return { success: false, error: 'Email not found in queue' };
|
|
@@ -82,22 +82,10 @@ export class EmailOpsHandler {
|
|
|
82
82
|
*/
|
|
83
83
|
private getAllQueueEmails(): interfaces.requests.IEmail[] {
|
|
84
84
|
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
|
85
|
-
if (!emailServer
|
|
85
|
+
if (!emailServer) {
|
|
86
86
|
return [];
|
|
87
87
|
}
|
|
88
|
-
|
|
89
|
-
const queue = emailServer.deliveryQueue;
|
|
90
|
-
const queueMap = (queue as any).queue as Map<string, any>;
|
|
91
|
-
|
|
92
|
-
if (!queueMap) {
|
|
93
|
-
return [];
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const emails: interfaces.requests.IEmail[] = [];
|
|
97
|
-
|
|
98
|
-
for (const [id, item] of queueMap.entries()) {
|
|
99
|
-
emails.push(this.mapQueueItemToEmail(item));
|
|
100
|
-
}
|
|
88
|
+
const emails = emailServer.getQueueItems().map((item) => this.mapQueueItemToEmail(item));
|
|
101
89
|
|
|
102
90
|
// Sort by createdAt descending (newest first)
|
|
103
91
|
emails.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
@@ -110,12 +98,10 @@ export class EmailOpsHandler {
|
|
|
110
98
|
*/
|
|
111
99
|
private getEmailDetail(emailId: string): interfaces.requests.IEmailDetail | null {
|
|
112
100
|
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
|
113
|
-
if (!emailServer
|
|
101
|
+
if (!emailServer) {
|
|
114
102
|
return null;
|
|
115
103
|
}
|
|
116
|
-
|
|
117
|
-
const queue = emailServer.deliveryQueue;
|
|
118
|
-
const item = queue.getItem(emailId);
|
|
104
|
+
const item = emailServer.getQueueItem(emailId);
|
|
119
105
|
|
|
120
106
|
if (!item) {
|
|
121
107
|
return null;
|
|
@@ -530,13 +530,49 @@ export class StatsHandler {
|
|
|
530
530
|
nextRetry?: number;
|
|
531
531
|
}>;
|
|
532
532
|
}> {
|
|
533
|
-
|
|
533
|
+
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
|
534
|
+
if (!emailServer) {
|
|
535
|
+
return {
|
|
536
|
+
pending: 0,
|
|
537
|
+
active: 0,
|
|
538
|
+
failed: 0,
|
|
539
|
+
retrying: 0,
|
|
540
|
+
items: [],
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const queueStats = emailServer.getQueueStats();
|
|
545
|
+
const items = emailServer.getQueueItems()
|
|
546
|
+
.sort((a, b) => {
|
|
547
|
+
const left = a.createdAt instanceof Date ? a.createdAt.getTime() : new Date(a.createdAt).getTime();
|
|
548
|
+
const right = b.createdAt instanceof Date ? b.createdAt.getTime() : new Date(b.createdAt).getTime();
|
|
549
|
+
return right - left;
|
|
550
|
+
})
|
|
551
|
+
.slice(0, 50)
|
|
552
|
+
.map((item) => {
|
|
553
|
+
const emailLike = item.processingResult;
|
|
554
|
+
const recipients = Array.isArray(emailLike?.to)
|
|
555
|
+
? emailLike.to
|
|
556
|
+
: Array.isArray(emailLike?.email?.to)
|
|
557
|
+
? emailLike.email.to
|
|
558
|
+
: [];
|
|
559
|
+
const subject = emailLike?.subject || emailLike?.email?.subject || '';
|
|
560
|
+
return {
|
|
561
|
+
id: item.id,
|
|
562
|
+
recipient: recipients[0] || '',
|
|
563
|
+
subject,
|
|
564
|
+
status: item.status,
|
|
565
|
+
attempts: item.attempts,
|
|
566
|
+
nextRetry: item.nextAttempt instanceof Date ? item.nextAttempt.getTime() : undefined,
|
|
567
|
+
};
|
|
568
|
+
});
|
|
569
|
+
|
|
534
570
|
return {
|
|
535
|
-
pending:
|
|
536
|
-
active:
|
|
537
|
-
failed:
|
|
538
|
-
retrying:
|
|
539
|
-
items
|
|
571
|
+
pending: queueStats.status.pending,
|
|
572
|
+
active: queueStats.status.processing,
|
|
573
|
+
failed: queueStats.status.failed,
|
|
574
|
+
retrying: queueStats.status.deferred,
|
|
575
|
+
items,
|
|
540
576
|
};
|
|
541
577
|
}
|
|
542
578
|
|
|
@@ -600,4 +636,4 @@ export class StatsHandler {
|
|
|
600
636
|
],
|
|
601
637
|
};
|
|
602
638
|
}
|
|
603
|
-
}
|
|
639
|
+
}
|
|
@@ -374,7 +374,7 @@ export class OpsViewNetworkActivity extends DeesElement {
|
|
|
374
374
|
type: 'number',
|
|
375
375
|
icon: 'lucide:Plug',
|
|
376
376
|
color: activeConnections > 100 ? '#f59e0b' : '#22c55e',
|
|
377
|
-
description: `Total: ${this.
|
|
377
|
+
description: `Total: ${this.formatNumber(this.statsState.serverStats?.totalConnections || 0)} connections`,
|
|
378
378
|
actions: [
|
|
379
379
|
{
|
|
380
380
|
name: 'View Details',
|