@serve.zone/dcrouter 17.1.1 → 17.1.3

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.
@@ -12,6 +12,10 @@ import type {
12
12
  IEmailDomainOperationError,
13
13
  TEmailDomainLifecycleStatus,
14
14
  } from '../../ts_interfaces/data/email-domain.js';
15
+ import {
16
+ MAIL_DNS_MAX_RETRY_INTERVAL_MS,
17
+ MAIL_DNS_RETRY_INTERVAL_MS,
18
+ } from './classes.mail-dns-sync.js';
15
19
 
16
20
  export interface IEmailDomainManagerActionResult {
17
21
  success: boolean;
@@ -28,9 +32,20 @@ export interface IEmailDomainManagerActionResult {
28
32
 
29
33
  export interface IManagedDkimRepairResult {
30
34
  repairedDomainIds: string[];
35
+ repairedConfiguredDomains: string[];
36
+ failedConfiguredDomains: string[];
31
37
  failedDomainIds: string[];
32
38
  }
33
39
 
40
+ interface IDkimMaterialRepairResult {
41
+ regenerated: boolean;
42
+ }
43
+
44
+ interface IDkimDocumentRepairOutcome {
45
+ outcome?: 'repaired' | 'failed';
46
+ failureMessage?: string;
47
+ }
48
+
34
49
  /**
35
50
  * Persists managed email domains and projects them into SmartMTA. DNS mutation
36
51
  * is delegated exclusively to MailDnsSync.
@@ -38,6 +53,7 @@ export interface IManagedDkimRepairResult {
38
53
  export class EmailDomainManager {
39
54
  private dcRouter: any; // DcRouter — avoids circular import
40
55
  private baseEmailDomains: IEmailDomainConfig[] = [];
56
+ private failedBaseDkimDomainNames = new Set<string>();
41
57
  private mutationChain: Promise<unknown> = Promise.resolve();
42
58
  /** Explicit upstream capability marker; absence is deliberately fail-closed. */
43
59
  private get selectorCorrectSigningCapability(): boolean {
@@ -52,6 +68,10 @@ export class EmailDomainManager {
52
68
  public setBaseEmailDomains(domains: IEmailDomainConfig[] | undefined): void {
53
69
  this.baseEmailDomains = (domains || [])
54
70
  .map((domainConfig) => JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
71
+ const configuredNames = new Set(this.baseEmailDomains.map((domainConfig) => domainConfig.domain.toLowerCase()));
72
+ this.failedBaseDkimDomainNames = new Set(
73
+ [...this.failedBaseDkimDomainNames].filter((domainName) => configuredNames.has(domainName)),
74
+ );
55
75
  }
56
76
 
57
77
  private get dkimCreator(): any | undefined {
@@ -381,6 +401,8 @@ export class EmailDomainManager {
381
401
  public async repairManagedDkimBeforeEmailStart(): Promise<IManagedDkimRepairResult> {
382
402
  const repairedDomainIds: string[] = [];
383
403
  const failedDomainIds: string[] = [];
404
+ const repairedConfiguredDomains: string[] = [];
405
+ const failedConfiguredDomains: string[] = [];
384
406
 
385
407
  await this.runMutationExclusive(async () => {
386
408
  const dkimCreator = this.dkimCreator;
@@ -388,85 +410,10 @@ export class EmailDomainManager {
388
410
  throw new Error('DKIM key creator is not ready for managed-domain repair');
389
411
  }
390
412
 
391
- for (const doc of await EmailDomainDoc.findAll()) {
413
+ const managedDocs = await EmailDomainDoc.findAll();
414
+ for (const doc of managedDocs) {
392
415
  if (doc.reconciliation?.lifecycleStatus === 'deleting') continue;
393
- const currentActive = this.getActiveDkim(doc);
394
- const selector = currentActive?.selector || doc.dkim?.selector || 'default';
395
- const keySize = currentActive?.keySize || doc.dkim?.keySize || 2048;
396
- let regenerated = false;
397
- let outcome: 'repaired' | 'failed' | undefined;
398
- let failureMessage: string | undefined;
399
-
400
- try {
401
- try {
402
- await dkimCreator.readValidatedDKIMKeysForSelector(doc.domain, selector);
403
- } catch {
404
- await dkimCreator.createAndStoreDKIMKeysForSelector(doc.domain, selector, keySize);
405
- await dkimCreator.readValidatedDKIMKeysForSelector(doc.domain, selector);
406
- regenerated = true;
407
- }
408
-
409
- const record = await dkimCreator.getDNSRecordForSelector(doc.domain, selector);
410
- const publicKey = this.extractDkimPublicKey(record?.value);
411
- if (!publicKey) {
412
- throw new Error('validated DKIM material produced no usable public key');
413
- }
414
-
415
- if (
416
- !regenerated
417
- && !this.hasDkimRepairFailure(doc)
418
- && currentActive?.selector === selector
419
- && currentActive.publicKey === publicKey
420
- && doc.dkim?.selector === selector
421
- && doc.dkim.publicKey === publicKey
422
- ) {
423
- continue;
424
- }
425
-
426
- const now = new Date().toISOString();
427
- doc.activeDkim = {
428
- selector,
429
- keySize,
430
- publicKey,
431
- createdAt: regenerated ? now : currentActive?.createdAt || now,
432
- validatedAt: now,
433
- };
434
- doc.dkim = {
435
- ...doc.dkim,
436
- selector,
437
- keySize,
438
- publicKey,
439
- ...(regenerated ? { lastRotatedAt: now } : {}),
440
- };
441
- doc.dnsStatus = {
442
- ...doc.dnsStatus,
443
- dkim: 'unchecked',
444
- };
445
- doc.reconciliation = this.invalidateDkimReconciliation(
446
- doc,
447
- regenerated ? 'DKIM_KEY_MATERIAL_REGENERATED' : 'DKIM_KEY_MATERIAL_REPAIRED',
448
- regenerated
449
- ? 'Caller-managed DKIM key material was regenerated; DNS deployment must be revalidated'
450
- : 'Caller-managed DKIM metadata was repaired; DNS deployment must be revalidated',
451
- );
452
- doc.updatedAt = now;
453
- outcome = 'repaired';
454
- } catch (error: unknown) {
455
- const message = `Failed to repair caller-managed DKIM for ${selector}._domainkey.${doc.domain}: ${(error as Error).message}`;
456
- doc.reconciliation = this.invalidateDkimReconciliation(
457
- doc,
458
- 'DKIM_KEY_REPAIR_FAILED',
459
- message,
460
- false,
461
- );
462
- doc.dnsStatus = {
463
- ...doc.dnsStatus,
464
- dkim: 'invalid',
465
- };
466
- doc.updatedAt = new Date().toISOString();
467
- outcome = 'failed';
468
- failureMessage = message;
469
- }
416
+ const { outcome, failureMessage } = await this.repairManagedDkimDocument(doc, dkimCreator);
470
417
 
471
418
  // Persistence failures are global prerequisites, not per-domain key
472
419
  // failures. Keep this save outside the material-repair catch.
@@ -479,6 +426,28 @@ export class EmailDomainManager {
479
426
  }
480
427
  }
481
428
 
429
+ const managedNames = new Set(
430
+ managedDocs
431
+ .filter((doc) => doc.reconciliation?.lifecycleStatus !== 'deleting')
432
+ .map((doc) => doc.domain.toLowerCase()),
433
+ );
434
+ for (const domainConfig of this.baseEmailDomains) {
435
+ const domainName = domainConfig.domain.toLowerCase();
436
+ if (managedNames.has(domainName)) continue;
437
+ const selector = domainConfig.dkim?.selector || 'default';
438
+ const keySize = domainConfig.dkim?.keySize || 2048;
439
+ try {
440
+ const result = await this.ensureValidatedDkimMaterial(domainName, selector, keySize, dkimCreator);
441
+ const wasFailed = this.failedBaseDkimDomainNames.delete(domainName);
442
+ if (result.regenerated || wasFailed) repairedConfiguredDomains.push(domainName);
443
+ } catch (error: unknown) {
444
+ const message = `Failed to repair caller-managed DKIM for ${selector}._domainkey.${domainName}: ${(error as Error).message}`;
445
+ this.failedBaseDkimDomainNames.add(domainName);
446
+ failedConfiguredDomains.push(domainName);
447
+ logger.log('error', message);
448
+ }
449
+ }
450
+
482
451
  await this.syncManagedDomainsToRuntime({ excludeDkimRepairFailures: true });
483
452
  });
484
453
 
@@ -488,7 +457,137 @@ export class EmailDomainManager {
488
457
  [...new Set(repairedDomainIds)],
489
458
  );
490
459
  }
491
- return { repairedDomainIds, failedDomainIds };
460
+ return {
461
+ repairedDomainIds,
462
+ repairedConfiguredDomains,
463
+ failedConfiguredDomains,
464
+ failedDomainIds,
465
+ };
466
+ }
467
+
468
+ /** Retry a previously failed managed-domain repair when its bounded retry is due. */
469
+ public async repairManagedDkimIfDue(id: string, now = Date.now()): Promise<EmailDomainDoc | undefined> {
470
+ return await this.runMutationExclusive(async () => {
471
+ const doc = await EmailDomainDoc.findById(id);
472
+ if (!doc || !this.hasDkimRepairFailure(doc)) return doc || undefined;
473
+ const retryAt = Date.parse(doc.reconciliation?.retryAt || '');
474
+ if (Number.isFinite(retryAt) && retryAt > now) return doc;
475
+ const dkimCreator = this.dkimCreator;
476
+ if (!dkimCreator) return doc;
477
+
478
+ const result = await this.repairManagedDkimDocument(doc, dkimCreator);
479
+ await doc.save();
480
+ await this.syncManagedDomainsToRuntime({ excludeDkimRepairFailures: true });
481
+ if (result.outcome === 'failed') logger.log('error', result.failureMessage!);
482
+ return doc;
483
+ });
484
+ }
485
+
486
+ private async repairManagedDkimDocument(
487
+ doc: EmailDomainDoc,
488
+ dkimCreator: any,
489
+ ): Promise<IDkimDocumentRepairOutcome> {
490
+ const currentActive = this.getActiveDkim(doc);
491
+ const selector = currentActive?.selector || doc.dkim?.selector || 'default';
492
+ const keySize = currentActive?.keySize || doc.dkim?.keySize || 2048;
493
+ try {
494
+ const { regenerated } = await this.ensureValidatedDkimMaterial(
495
+ doc.domain,
496
+ selector,
497
+ keySize,
498
+ dkimCreator,
499
+ );
500
+ const record = await dkimCreator.getDNSRecordForSelector(doc.domain, selector);
501
+ const publicKey = this.extractDkimPublicKey(record?.value);
502
+ if (!publicKey) throw new Error('validated DKIM material produced no usable public key');
503
+
504
+ if (
505
+ !regenerated
506
+ && !this.hasDkimRepairFailure(doc)
507
+ && currentActive?.selector === selector
508
+ && currentActive.publicKey === publicKey
509
+ && doc.dkim?.selector === selector
510
+ && doc.dkim.publicKey === publicKey
511
+ ) return {};
512
+
513
+ const timestamp = new Date().toISOString();
514
+ doc.activeDkim = {
515
+ selector,
516
+ keySize,
517
+ publicKey,
518
+ createdAt: regenerated ? timestamp : currentActive?.createdAt || timestamp,
519
+ validatedAt: timestamp,
520
+ };
521
+ doc.dkim = {
522
+ ...doc.dkim,
523
+ selector,
524
+ keySize,
525
+ publicKey,
526
+ ...(regenerated ? { lastRotatedAt: timestamp } : {}),
527
+ };
528
+ doc.dnsStatus = { ...doc.dnsStatus, dkim: 'unchecked' };
529
+ doc.reconciliation = this.invalidateDkimReconciliation(
530
+ doc,
531
+ regenerated ? 'DKIM_KEY_MATERIAL_REGENERATED' : 'DKIM_KEY_MATERIAL_REPAIRED',
532
+ regenerated
533
+ ? 'Caller-managed DKIM key material was regenerated; DNS deployment must be revalidated'
534
+ : 'Caller-managed DKIM metadata was repaired; DNS deployment must be revalidated',
535
+ );
536
+ doc.updatedAt = timestamp;
537
+ return { outcome: 'repaired' };
538
+ } catch (error: unknown) {
539
+ const message = `Failed to repair caller-managed DKIM for ${selector}._domainkey.${doc.domain}: ${(error as Error).message}`;
540
+ const previousAttempt = doc.reconciliation?.retryAttempt || 0;
541
+ const retryAttempt = previousAttempt + 1;
542
+ const retryDelay = Math.min(
543
+ MAIL_DNS_RETRY_INTERVAL_MS * (2 ** Math.max(0, retryAttempt - 1)),
544
+ MAIL_DNS_MAX_RETRY_INTERVAL_MS,
545
+ );
546
+ doc.reconciliation = this.invalidateDkimReconciliation(doc, 'DKIM_KEY_REPAIR_FAILED', message);
547
+ doc.reconciliation.retryAttempt = retryAttempt;
548
+ doc.reconciliation.retryAt = new Date(Date.now() + retryDelay).toISOString();
549
+ doc.dnsStatus = { ...doc.dnsStatus, dkim: 'invalid' };
550
+ doc.updatedAt = new Date().toISOString();
551
+ return { outcome: 'failed', failureMessage: message };
552
+ }
553
+ }
554
+
555
+ private async ensureValidatedDkimMaterial(
556
+ domain: string,
557
+ selector: string,
558
+ keySize: number,
559
+ dkimCreator: any,
560
+ ): Promise<IDkimMaterialRepairResult> {
561
+ let initialError: unknown;
562
+ try {
563
+ await dkimCreator.readValidatedDKIMKeysForSelector(domain, selector);
564
+ return { regenerated: false };
565
+ } catch (error: unknown) {
566
+ initialError = error;
567
+ }
568
+
569
+ let creationError: unknown;
570
+ try {
571
+ await dkimCreator.createAndStoreDKIMKeysForSelector(domain, selector, keySize);
572
+ } catch (error: unknown) {
573
+ creationError = error;
574
+ }
575
+
576
+ try {
577
+ await dkimCreator.readValidatedDKIMKeysForSelector(domain, selector);
578
+ // Creation may have stored a complete key pair before a metadata write
579
+ // failed. Treat any attempted regeneration as a DNS-changing operation.
580
+ return { regenerated: true };
581
+ } catch (finalError: unknown) {
582
+ const parts = [
583
+ `initial validation failed: ${(initialError as Error).message}`,
584
+ creationError
585
+ ? `regeneration failed: ${(creationError as Error).message}`
586
+ : 'regeneration completed',
587
+ `final validation failed: ${(finalError as Error).message}`,
588
+ ];
589
+ throw new Error(parts.join('; '));
590
+ }
492
591
  }
493
592
 
494
593
  public async requestDkimRotation(id: string): Promise<IEmailDomainManagerActionResult> {
@@ -623,6 +722,7 @@ export class EmailDomainManager {
623
722
  errors: _errors,
624
723
  lastSuccessAt: _lastSuccessAt,
625
724
  retryAt: _retryAt,
725
+ retryAttempt: _retryAttempt,
626
726
  ...preserved
627
727
  } = previous;
628
728
  const alreadyInvalidated = (
@@ -763,9 +863,10 @@ export class EmailDomainManager {
763
863
  const mergedDomains = new Map<string, IEmailDomainConfig>();
764
864
  for (const domainConfig of this.baseEmailDomains) {
765
865
  const key = domainConfig.domain.toLowerCase();
766
- if (failedDomainNames.has(key) && optionsArg.excludeDkimRepairFailures) continue;
866
+ const dkimRepairFailed = failedDomainNames.has(key) || this.failedBaseDkimDomainNames.has(key);
867
+ if (dkimRepairFailed && optionsArg.excludeDkimRepairFailures) continue;
767
868
  const projectedConfig = JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig;
768
- if (failedDomainNames.has(key)) delete projectedConfig.dkim;
869
+ if (dkimRepairFailed) delete projectedConfig.dkim;
769
870
  mergedDomains.set(key, projectedConfig);
770
871
  }
771
872
  for (const managedConfig of await this.buildManagedDomainConfigs(optionsArg)) {
@@ -32,6 +32,11 @@ export const MAIL_DNS_SYNC_CREATED_BY = 'mail-dns-reconciler';
32
32
  export const MAIL_DNS_MANAGED_BY = 'mail-dns-reconciler';
33
33
  export const MAIL_DNS_RETRY_INTERVAL_MS = 5 * 60_000;
34
34
  export const MAIL_DNS_MAX_RETRY_INTERVAL_MS = 60 * 60_000;
35
+ export const MAIL_DNS_PUBLIC_RESOLVER_OPTIONS = {
36
+ strategy: 'doh' as const,
37
+ allowDohFallback: false,
38
+ timeoutMs: 5_000,
39
+ };
35
40
 
36
41
  export interface IMailDnsVerificationResult {
37
42
  valid: boolean;
@@ -150,7 +155,7 @@ export class SmartDnsMailVerifier implements IMailDnsVerifier {
150
155
  private client?: plugins.smartdns.dnsClientMod.Smartdns;
151
156
 
152
157
  private getClient(): plugins.smartdns.dnsClientMod.Smartdns {
153
- this.client ||= new plugins.smartdns.dnsClientMod.Smartdns({});
158
+ this.client ||= new plugins.smartdns.dnsClientMod.Smartdns(MAIL_DNS_PUBLIC_RESOLVER_OPTIONS);
154
159
  return this.client;
155
160
  }
156
161
 
@@ -476,8 +481,20 @@ export class MailDnsSync {
476
481
  const plans: IPlannedDomain[] = [];
477
482
  const deletingDocs: EmailDomainDoc[] = [];
478
483
  for (const loadedDoc of docs) {
479
- const doc = await this.dcRouterRef.emailDomainManager?.stageDkimRotationIfDue?.(loadedDoc.id)
484
+ if (loadedDoc.reconciliation?.lifecycleStatus === 'deleting') {
485
+ await this.persistDeletingAttempt(loadedDoc);
486
+ deletingDocs.push(loadedDoc);
487
+ continue;
488
+ }
489
+ const repairedDoc = await this.dcRouterRef.emailDomainManager?.repairManagedDkimIfDue?.(loadedDoc.id)
480
490
  || loadedDoc;
491
+ if (repairedDoc.reconciliation?.errors.some((error) => error.code === 'DKIM_KEY_REPAIR_FAILED')) {
492
+ // Key repair owns this retry schedule. DNS planning here would publish
493
+ // stale DKIM material and overwrite the bounded repair backoff.
494
+ continue;
495
+ }
496
+ const doc = await this.dcRouterRef.emailDomainManager?.stageDkimRotationIfDue?.(repairedDoc.id)
497
+ || repairedDoc;
481
498
  if (doc.reconciliation?.lifecycleStatus === 'deleting') {
482
499
  await this.persistDeletingAttempt(doc);
483
500
  deletingDocs.push(doc);
@@ -1243,9 +1260,16 @@ export class MailDnsSync {
1243
1260
  ? {}
1244
1261
  : { retryAt: new Date(Date.now() + retryDelay).toISOString() }),
1245
1262
  };
1263
+ const mailPublicationReady = plan.egressIdentitySupported
1264
+ && plan.revision.edgeIdentities.length > 0
1265
+ && plan.revision.mxSlots.length > 0;
1246
1266
  plan.doc.dnsStatus = {
1247
- mx: this.statusForIntent(plan.intents, (intent) => intent.type === 'MX'),
1248
- spf: this.statusForIntent(plan.intents, (intent) => intent.type === 'TXT' && intent.value.startsWith('v=spf1')),
1267
+ mx: mailPublicationReady
1268
+ ? this.statusForIntent(plan.intents, (intent) => intent.type === 'MX')
1269
+ : 'unchecked',
1270
+ spf: mailPublicationReady
1271
+ ? this.statusForIntent(plan.intents, (intent) => intent.type === 'TXT' && intent.value.startsWith('v=spf1'))
1272
+ : 'unchecked',
1249
1273
  dkim: this.statusForIntent(plan.intents, (intent) => intent.type === 'TXT' && intent.name.includes('._domainkey.')),
1250
1274
  dmarc: this.statusForIntent(plan.intents, (intent) => intent.type === 'TXT' && intent.value.startsWith('v=DMARC1')),
1251
1275
  lastCheckedAt: now,
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '17.1.1',
6
+ version: '17.1.3',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }