@push.rocks/smartmta 6.5.0 → 6.5.1

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.
@@ -30,6 +30,20 @@ interface IDnsRecords {
30
30
  ns?: string[];
31
31
  }
32
32
 
33
+ interface IDnsResolver {
34
+ setServers(servers: string[]): void;
35
+ resolveMx(domain: string): Promise<Array<{ exchange: string; priority: number }>>;
36
+ resolveTxt(domain: string): Promise<string[][]>;
37
+ resolveNs(domain: string): Promise<string[]>;
38
+ cancel(): void;
39
+ }
40
+
41
+ interface IDnsValidationRun {
42
+ id: number;
43
+ abortController: AbortController;
44
+ cancel: () => void;
45
+ }
46
+
33
47
  /**
34
48
  * Manages DNS configuration for email domains
35
49
  * Handles both validation and creation of DNS records
@@ -37,37 +51,54 @@ interface IDnsRecords {
37
51
  export class DnsManager {
38
52
  private dcRouter: IDcRouterLike;
39
53
  private storageManager: IStorageManagerLike;
54
+ private resolverFactory: () => IDnsResolver;
55
+ private activeResolvers = new Set<IDnsResolver>();
56
+ private activeValidation?: IDnsValidationRun;
57
+ private validationRunId = 0;
40
58
 
41
- constructor(dcRouter: IDcRouterLike) {
59
+ constructor(
60
+ dcRouter: IDcRouterLike,
61
+ resolverFactory: () => IDnsResolver = () => new plugins.dns.promises.Resolver()
62
+ ) {
42
63
  this.dcRouter = dcRouter;
43
64
  this.storageManager = dcRouter.storageManager;
65
+ this.resolverFactory = resolverFactory;
44
66
  }
45
67
 
46
68
  /**
47
69
  * Validate all domain configurations
48
70
  */
49
- async validateAllDomains(domainConfigs: IEmailDomainConfig[]): Promise<Map<string, IDnsValidationResult>> {
50
- const results = new Map<string, IDnsValidationResult>();
51
-
52
- for (const config of domainConfigs) {
53
- const result = await this.validateDomain(config);
54
- results.set(config.domain, result);
71
+ async validateAllDomains(
72
+ domainConfigs: IEmailDomainConfig[],
73
+ abortSignal?: AbortSignal
74
+ ): Promise<Map<string, IDnsValidationResult>> {
75
+ if (abortSignal?.aborted) {
76
+ return new Map();
55
77
  }
56
-
57
- return results;
78
+ const results = await Promise.all(domainConfigs.map(async (config) => {
79
+ return [config.domain, await this.validateDomain(config, abortSignal)] as const;
80
+ }));
81
+
82
+ if (abortSignal?.aborted) {
83
+ return new Map();
84
+ }
85
+ return new Map(results);
58
86
  }
59
87
 
60
88
  /**
61
89
  * Validate a single domain configuration
62
90
  */
63
- async validateDomain(config: IEmailDomainConfig): Promise<IDnsValidationResult> {
91
+ async validateDomain(
92
+ config: IEmailDomainConfig,
93
+ abortSignal?: AbortSignal
94
+ ): Promise<IDnsValidationResult> {
64
95
  switch (config.dnsMode) {
65
96
  case 'forward':
66
97
  return this.validateForwardMode(config);
67
98
  case 'internal-dns':
68
- return this.validateInternalDnsMode(config);
99
+ return this.validateInternalDnsMode(config, abortSignal);
69
100
  case 'external-dns':
70
- return this.validateExternalDnsMode(config);
101
+ return this.validateExternalDnsMode(config, abortSignal);
71
102
  default:
72
103
  return {
73
104
  valid: false,
@@ -105,7 +136,10 @@ export class DnsManager {
105
136
  /**
106
137
  * Validate internal DNS mode configuration
107
138
  */
108
- private async validateInternalDnsMode(config: IEmailDomainConfig): Promise<IDnsValidationResult> {
139
+ private async validateInternalDnsMode(
140
+ config: IEmailDomainConfig,
141
+ abortSignal?: AbortSignal
142
+ ): Promise<IDnsValidationResult> {
109
143
  const result: IDnsValidationResult = {
110
144
  valid: true,
111
145
  errors: [],
@@ -164,7 +198,10 @@ export class DnsManager {
164
198
 
165
199
  // Check NS delegation
166
200
  try {
167
- const nsRecords = await this.resolveNs(config.domain);
201
+ const nsRecords = await this.resolveNs(config.domain, abortSignal);
202
+ if (abortSignal?.aborted) {
203
+ return result;
204
+ }
168
205
  const delegatedNameservers = dnsNsDomains.filter(ns => nsRecords.includes(ns));
169
206
  const isDelegated = delegatedNameservers.length > 0;
170
207
 
@@ -192,6 +229,9 @@ export class DnsManager {
192
229
  );
193
230
  }
194
231
  } catch (error) {
232
+ if (abortSignal?.aborted) {
233
+ return result;
234
+ }
195
235
  result.warnings.push(
196
236
  `Could not verify NS delegation for ${config.domain}: ${error.message}`
197
237
  );
@@ -203,7 +243,10 @@ export class DnsManager {
203
243
  /**
204
244
  * Validate external DNS mode configuration
205
245
  */
206
- private async validateExternalDnsMode(config: IEmailDomainConfig): Promise<IDnsValidationResult> {
246
+ private async validateExternalDnsMode(
247
+ config: IEmailDomainConfig,
248
+ abortSignal?: AbortSignal
249
+ ): Promise<IDnsValidationResult> {
207
250
  const result: IDnsValidationResult = {
208
251
  valid: true,
209
252
  errors: [],
@@ -213,7 +256,10 @@ export class DnsManager {
213
256
 
214
257
  try {
215
258
  // Get current DNS records
216
- const records = await this.checkDnsRecords(config);
259
+ const records = await this.checkDnsRecords(config, abortSignal);
260
+ if (abortSignal?.aborted) {
261
+ return result;
262
+ }
217
263
  const requiredRecords = config.dns?.external?.requiredRecords || ['MX', 'SPF', 'DKIM', 'DMARC'];
218
264
 
219
265
  // Check MX record
@@ -233,22 +279,9 @@ export class DnsManager {
233
279
  // Check DKIM record
234
280
  if (requiredRecords.includes('DKIM') && !records.dkim) {
235
281
  const selector = config.dkim?.selector || 'default';
236
- const dkimPublicKey = await this.storageManager.get(`/email/dkim/${config.domain}/public.key`);
237
-
238
- if (dkimPublicKey) {
239
- const publicKeyBase64 = dkimPublicKey
240
- .replace(/-----BEGIN PUBLIC KEY-----/g, '')
241
- .replace(/-----END PUBLIC KEY-----/g, '')
242
- .replace(/\s/g, '');
243
-
244
- result.requiredChanges.push(
245
- `Add TXT record: ${selector}._domainkey.${config.domain} -> "v=DKIM1; k=rsa; p=${publicKeyBase64}"`
246
- );
247
- } else {
248
- result.warnings.push(
249
- `DKIM public key not found for ${config.domain}. It will be generated on first use.`
250
- );
251
- }
282
+ result.requiredChanges.push(
283
+ `Add TXT record: ${selector}._domainkey.${config.domain} -> "v=DKIM1; k=rsa; p=<generated-public-key>"`
284
+ );
252
285
  }
253
286
 
254
287
  // Check DMARC record
@@ -269,6 +302,9 @@ export class DnsManager {
269
302
  }
270
303
 
271
304
  } catch (error) {
305
+ if (abortSignal?.aborted) {
306
+ return result;
307
+ }
272
308
  result.errors.push(`DNS validation failed: ${error.message}`);
273
309
  result.valid = false;
274
310
  }
@@ -279,62 +315,80 @@ export class DnsManager {
279
315
  /**
280
316
  * Check DNS records for a domain
281
317
  */
282
- private async checkDnsRecords(config: IEmailDomainConfig): Promise<IDnsRecords> {
318
+ private async checkDnsRecords(
319
+ config: IEmailDomainConfig,
320
+ abortSignal?: AbortSignal
321
+ ): Promise<IDnsRecords> {
283
322
  const records: IDnsRecords = {};
284
323
  const baseDomain = this.getBaseDomain(config.domain);
285
324
  const selector = config.dkim?.selector || 'default';
286
325
 
287
326
  // Use custom DNS servers if specified
288
- const resolver = new plugins.dns.promises.Resolver();
327
+ const resolver = this.createTrackedResolver();
289
328
  if (config.dns?.external?.servers?.length) {
290
329
  resolver.setServers(config.dns.external.servers);
291
330
  }
292
-
293
- // Check MX records
294
- try {
295
- const mxRecords = await resolver.resolveMx(baseDomain);
296
- records.mx = mxRecords.map(mx => mx.exchange);
297
- } catch (error) {
298
- logger.log('debug', `No MX records found for ${baseDomain}`);
299
- }
300
-
301
- // Check SPF record
302
- try {
303
- const txtRecords = await resolver.resolveTxt(baseDomain);
304
- const spfRecord = txtRecords.find(records =>
305
- records.some(record => record.startsWith('v=spf1'))
306
- );
307
- if (spfRecord) {
308
- records.spf = spfRecord.join('');
309
- }
310
- } catch (error) {
311
- logger.log('debug', `No SPF record found for ${baseDomain}`);
312
- }
313
-
314
- // Check DKIM record
315
- try {
316
- const dkimRecords = await resolver.resolveTxt(`${selector}._domainkey.${config.domain}`);
317
- const dkimRecord = dkimRecords.find(records =>
318
- records.some(record => record.includes('v=DKIM1'))
319
- );
320
- if (dkimRecord) {
321
- records.dkim = dkimRecord.join('');
322
- }
323
- } catch (error) {
324
- logger.log('debug', `No DKIM record found for ${selector}._domainkey.${config.domain}`);
325
- }
326
-
327
- // Check DMARC record
331
+
328
332
  try {
329
- const dmarcRecords = await resolver.resolveTxt(`_dmarc.${baseDomain}`);
330
- const dmarcRecord = dmarcRecords.find(records =>
331
- records.some(record => record.startsWith('v=DMARC1'))
332
- );
333
- if (dmarcRecord) {
334
- records.dmarc = dmarcRecord.join('');
335
- }
336
- } catch (error) {
337
- logger.log('debug', `No DMARC record found for _dmarc.${baseDomain}`);
333
+ await Promise.all([
334
+ (async () => {
335
+ try {
336
+ const mxRecords = await resolver.resolveMx(baseDomain);
337
+ records.mx = mxRecords.map(mx => mx.exchange);
338
+ } catch {
339
+ if (!abortSignal?.aborted) {
340
+ logger.log('debug', `No MX records found for ${baseDomain}`);
341
+ }
342
+ }
343
+ })(),
344
+ (async () => {
345
+ try {
346
+ const txtRecords = await resolver.resolveTxt(baseDomain);
347
+ const spfRecord = txtRecords.find(txtParts =>
348
+ txtParts.some(record => record.startsWith('v=spf1'))
349
+ );
350
+ if (spfRecord) {
351
+ records.spf = spfRecord.join('');
352
+ }
353
+ } catch {
354
+ if (!abortSignal?.aborted) {
355
+ logger.log('debug', `No SPF record found for ${baseDomain}`);
356
+ }
357
+ }
358
+ })(),
359
+ (async () => {
360
+ try {
361
+ const dkimRecords = await resolver.resolveTxt(`${selector}._domainkey.${config.domain}`);
362
+ const dkimRecord = dkimRecords.find(txtParts =>
363
+ txtParts.some(record => record.includes('v=DKIM1'))
364
+ );
365
+ if (dkimRecord) {
366
+ records.dkim = dkimRecord.join('');
367
+ }
368
+ } catch {
369
+ if (!abortSignal?.aborted) {
370
+ logger.log('debug', `No DKIM record found for ${selector}._domainkey.${config.domain}`);
371
+ }
372
+ }
373
+ })(),
374
+ (async () => {
375
+ try {
376
+ const dmarcRecords = await resolver.resolveTxt(`_dmarc.${baseDomain}`);
377
+ const dmarcRecord = dmarcRecords.find(txtParts =>
378
+ txtParts.some(record => record.startsWith('v=DMARC1'))
379
+ );
380
+ if (dmarcRecord) {
381
+ records.dmarc = dmarcRecord.join('');
382
+ }
383
+ } catch {
384
+ if (!abortSignal?.aborted) {
385
+ logger.log('debug', `No DMARC record found for _dmarc.${baseDomain}`);
386
+ }
387
+ }
388
+ })(),
389
+ ]);
390
+ } finally {
391
+ this.activeResolvers.delete(resolver);
338
392
  }
339
393
 
340
394
  return records;
@@ -343,16 +397,37 @@ export class DnsManager {
343
397
  /**
344
398
  * Resolve NS records for a domain
345
399
  */
346
- private async resolveNs(domain: string): Promise<string[]> {
400
+ private async resolveNs(domain: string, abortSignal?: AbortSignal): Promise<string[]> {
401
+ const resolver = this.createTrackedResolver();
347
402
  try {
348
- const resolver = new plugins.dns.promises.Resolver();
349
403
  const nsRecords = await resolver.resolveNs(domain);
350
404
  return nsRecords;
351
405
  } catch (error) {
352
- logger.log('warn', `Failed to resolve NS records for ${domain}: ${error.message}`);
406
+ if (!abortSignal?.aborted) {
407
+ logger.log('warn', `Failed to resolve NS records for ${domain}: ${error.message}`);
408
+ }
353
409
  return [];
410
+ } finally {
411
+ this.activeResolvers.delete(resolver);
354
412
  }
355
413
  }
414
+
415
+ private createTrackedResolver(): IDnsResolver {
416
+ const resolver = this.resolverFactory();
417
+ this.activeResolvers.add(resolver);
418
+ return resolver;
419
+ }
420
+
421
+ private cancelActiveResolvers(): void {
422
+ for (const resolver of this.activeResolvers) {
423
+ try {
424
+ resolver.cancel();
425
+ } catch {
426
+ // Resolver may already have completed or been cancelled.
427
+ }
428
+ }
429
+ this.activeResolvers.clear();
430
+ }
356
431
 
357
432
  /**
358
433
  * Get base domain from email domain (e.g., mail.example.com -> example.com)
@@ -373,17 +448,10 @@ export class DnsManager {
373
448
  return parts.slice(-2).join('.');
374
449
  }
375
450
 
376
- /**
377
- * Ensure all DNS records are created for configured domains
378
- * This is the main entry point for DNS record management
379
- */
380
- async ensureDnsRecords(domainConfigs: IEmailDomainConfig[], dkimCreator?: any): Promise<void> {
381
- logger.log('info', `Ensuring DNS records for ${domainConfigs.length} domains`);
382
-
383
- // First, validate all domains
384
- const validationResults = await this.validateAllDomains(domainConfigs);
385
-
386
- // Then create records for internal-dns domains
451
+ /** Provision deterministic local DNS records without performing network lookups. */
452
+ async provisionLocalDnsRecords(domainConfigs: IEmailDomainConfig[], dkimCreator?: any): Promise<void> {
453
+ logger.log('info', `Provisioning local DNS records for ${domainConfigs.length} domains`);
454
+
387
455
  const internalDnsDomains = domainConfigs.filter(config => config.dnsMode === 'internal-dns');
388
456
  if (internalDnsDomains.length > 0) {
389
457
  await this.createInternalDnsRecords(internalDnsDomains);
@@ -393,8 +461,81 @@ export class DnsManager {
393
461
  await this.createDkimRecords(domainConfigs, dkimCreator);
394
462
  }
395
463
  }
396
-
397
- // Log validation results for external-dns domains
464
+ }
465
+
466
+ /** Validate public DNS state with a single bounded, cancellable validation run. */
467
+ async validateAndReportDnsRecords(
468
+ domainConfigs: IEmailDomainConfig[],
469
+ timeoutMs = 30_000
470
+ ): Promise<void> {
471
+ this.cancelValidation();
472
+
473
+ const runId = ++this.validationRunId;
474
+ const abortController = new AbortController();
475
+ let cancelRun!: () => void;
476
+ const cancellationPromise = new Promise<{ kind: 'cancelled' }>((resolve) => {
477
+ cancelRun = () => resolve({ kind: 'cancelled' });
478
+ });
479
+ this.activeValidation = { id: runId, abortController, cancel: cancelRun };
480
+
481
+ let timeout: NodeJS.Timeout | undefined;
482
+ const timeoutPromise = new Promise<{ kind: 'timeout' }>((resolve) => {
483
+ timeout = setTimeout(() => {
484
+ abortController.abort();
485
+ this.cancelActiveResolvers();
486
+ resolve({ kind: 'timeout' });
487
+ }, timeoutMs);
488
+ });
489
+ const validationPromise = this.validateAllDomains(
490
+ domainConfigs,
491
+ abortController.signal
492
+ ).then((results) => ({
493
+ kind: 'completed' as const,
494
+ results,
495
+ }));
496
+
497
+ try {
498
+ const outcome = await Promise.race([
499
+ validationPromise,
500
+ timeoutPromise,
501
+ cancellationPromise,
502
+ ]);
503
+
504
+ if (outcome.kind === 'cancelled') {
505
+ return;
506
+ }
507
+ if (outcome.kind === 'timeout') {
508
+ throw new Error(`DNS validation timed out after ${timeoutMs} ms`);
509
+ }
510
+ if (this.activeValidation?.id !== runId) {
511
+ return;
512
+ }
513
+
514
+ this.reportValidationResults(domainConfigs, outcome.results);
515
+ } finally {
516
+ if (timeout) {
517
+ clearTimeout(timeout);
518
+ }
519
+ if (this.activeValidation?.id === runId) {
520
+ this.activeValidation = undefined;
521
+ this.cancelActiveResolvers();
522
+ }
523
+ }
524
+ }
525
+
526
+ /** Cancel the current validation run and all DNS requests owned by it. */
527
+ cancelValidation(): void {
528
+ const activeValidation = this.activeValidation;
529
+ this.activeValidation = undefined;
530
+ activeValidation?.abortController.abort();
531
+ activeValidation?.cancel();
532
+ this.cancelActiveResolvers();
533
+ }
534
+
535
+ private reportValidationResults(
536
+ domainConfigs: IEmailDomainConfig[],
537
+ validationResults: Map<string, IDnsValidationResult>
538
+ ): void {
398
539
  for (const [domain, result] of validationResults) {
399
540
  const config = domainConfigs.find(c => c.domain === domain);
400
541
  if (config?.dnsMode === 'external-dns' && result.requiredChanges.length > 0) {
@@ -402,6 +543,15 @@ export class DnsManager {
402
543
  }
403
544
  }
404
545
  }
546
+
547
+ /**
548
+ * Ensure DNS records and validate public DNS state.
549
+ * Prefer the split lifecycle methods when validation must not block readiness.
550
+ */
551
+ async ensureDnsRecords(domainConfigs: IEmailDomainConfig[], dkimCreator?: any): Promise<void> {
552
+ await this.provisionLocalDnsRecords(domainConfigs, dkimCreator);
553
+ await this.validateAndReportDnsRecords(domainConfigs);
554
+ }
405
555
 
406
556
  /**
407
557
  * Create DNS records for internal-dns mode domains
@@ -254,6 +254,10 @@ export class UnifiedEmailServer extends EventEmitter {
254
254
  // Extracted subsystems
255
255
  private actionExecutor: EmailActionExecutor;
256
256
  private dkimManager: DkimManager;
257
+ private dnsManager: DnsManager;
258
+ private dnsValidationTask?: Promise<void>;
259
+ private dnsValidationGeneration = 0;
260
+ private localDnsProvisioned = false;
257
261
  private bridgeStateChangeHandler?: (event: { oldState: string; newState: string }) => void;
258
262
  private bridgeRcptToRequestHandler?: (data: IRcptToRequestEvent) => void;
259
263
  private bridgeEmailReceivedHandler?: (data: IEmailReceivedEvent) => void;
@@ -343,6 +347,7 @@ export class UnifiedEmailServer extends EventEmitter {
343
347
 
344
348
  // Initialize DKIM manager
345
349
  this.dkimManager = new DkimManager(this.dkimCreator, this.domainRegistry, dcRouter, this.rustBridge);
350
+ this.dnsManager = new DnsManager(this.dcRouter);
346
351
 
347
352
  // Initialize statistics
348
353
  this.stats = {
@@ -469,6 +474,7 @@ export class UnifiedEmailServer extends EventEmitter {
469
474
  await this.initializeDkimAndDns();
470
475
  this.registerBridgeEventHandlers();
471
476
  await this.startSmtpServer();
477
+ this.startDnsValidation();
472
478
  logger.log('info', 'UnifiedEmailServer started successfully');
473
479
  this.emit('started');
474
480
  } catch (error) {
@@ -510,9 +516,14 @@ export class UnifiedEmailServer extends EventEmitter {
510
516
  await this.dkimManager.setupDkimForDomains();
511
517
  logger.log('info', 'DKIM configuration completed for all domains');
512
518
 
513
- const dnsManager = new DnsManager(this.dcRouter);
514
- await dnsManager.ensureDnsRecords(this.domainRegistry.getAllConfigs(), this.dkimCreator);
515
- logger.log('info', 'DNS records ensured for all configured domains');
519
+ if (!this.localDnsProvisioned) {
520
+ await this.dnsManager.provisionLocalDnsRecords(
521
+ this.domainRegistry.getAllConfigs(),
522
+ this.dkimCreator
523
+ );
524
+ this.localDnsProvisioned = true;
525
+ logger.log('info', 'Local DNS records provisioned for all configured domains');
526
+ }
516
527
 
517
528
  this.applyDomainRateLimits();
518
529
  logger.log('info', 'Per-domain rate limits configured');
@@ -521,6 +532,34 @@ export class UnifiedEmailServer extends EventEmitter {
521
532
  logger.log('info', 'DKIM key rotation check completed');
522
533
  }
523
534
 
535
+ private startDnsValidation(): void {
536
+ const generation = ++this.dnsValidationGeneration;
537
+ let task: Promise<void>;
538
+ task = this.dnsManager
539
+ .validateAndReportDnsRecords(this.domainRegistry.getAllConfigs())
540
+ .catch((error) => {
541
+ if (this.dnsValidationGeneration === generation) {
542
+ logger.log('warn', `Background DNS validation did not complete: ${(error as Error).message}`);
543
+ }
544
+ })
545
+ .finally(() => {
546
+ if (this.dnsValidationGeneration === generation && this.dnsValidationTask === task) {
547
+ this.dnsValidationTask = undefined;
548
+ }
549
+ });
550
+ this.dnsValidationTask = task;
551
+ }
552
+
553
+ private async stopDnsValidation(): Promise<void> {
554
+ this.dnsValidationGeneration++;
555
+ const task = this.dnsValidationTask;
556
+ this.dnsValidationTask = undefined;
557
+ this.dnsManager.cancelValidation();
558
+ if (task) {
559
+ await task;
560
+ }
561
+ }
562
+
524
563
  private registerBridgeEventHandlers(): void {
525
564
  this.unregisterBridgeEventHandlers();
526
565
 
@@ -692,6 +731,8 @@ export class UnifiedEmailServer extends EventEmitter {
692
731
  logger.log('info', 'Stopping UnifiedEmailServer');
693
732
 
694
733
  try {
734
+ await this.stopDnsValidation();
735
+
695
736
  // Stop the Rust SMTP server first
696
737
  try {
697
738
  await this.rustBridge.stopSmtpServer();