@serve.zone/dcrouter 13.17.9 → 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.
@@ -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.handleDKIMKeysForDomain(domainName);
81
- const dnsRecord = await this.dkimCreator.getDNSRecordForSelector(domainName, selector);
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: `v=DKIM1; h=sha256; k=rsa; p=${publicKey}`,
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
- doc.dnsStatus.mx = await this.checkMx(resolver, domain);
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, 'v=spf1');
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}`, 'v=DKIM1');
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}`, 'v=DMARC1');
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 async checkMx(resolver: plugins.dns.promises.Resolver, domain: string): Promise<TDnsRecordStatus> {
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
- return records && records.length > 0 ? 'valid' : 'missing';
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
- prefix: string,
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
- const found = flat.some((r) => r.startsWith(prefix));
298
- return found ? 'valid' : 'missing';
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
@@ -1 +1,2 @@
1
1
  export * from './classes.email-domain.manager.js';
2
+ export * from './classes.smartmta-storage-manager.js';
@@ -48,7 +48,7 @@ export class EmailOpsHandler {
48
48
  }
49
49
 
50
50
  const queue = emailServer.deliveryQueue;
51
- const item = queue.getItem(dataArg.emailId);
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?.deliveryQueue) {
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?.deliveryQueue) {
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
- // TODO: Implement actual queue status collection
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: 0,
536
- active: 0,
537
- failed: 0,
538
- retrying: 0,
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
+ }
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '13.17.9',
6
+ version: '13.18.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }