@serve.zone/dcrouter 18.6.2 → 18.7.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.
Files changed (39) hide show
  1. package/deno.json +1 -1
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/classes.dcrouter.js +4 -1
  4. package/dist_ts/dns/manager.dns.d.ts +16 -0
  5. package/dist_ts/dns/manager.dns.js +192 -39
  6. package/dist_ts/dns/providers/cloudflare.provider.d.ts +5 -0
  7. package/dist_ts/dns/providers/cloudflare.provider.js +78 -23
  8. package/dist_ts/dns/providers/interfaces.d.ts +2 -0
  9. package/dist_ts/email/classes.email-domain.manager.d.ts +1 -0
  10. package/dist_ts/email/classes.email-domain.manager.js +36 -8
  11. package/dist_ts/email/classes.mail-dns-sync.d.ts +8 -0
  12. package/dist_ts/email/classes.mail-dns-sync.js +239 -55
  13. package/dist_ts/email/classes.mail-edge-eligibility.js +44 -17
  14. package/dist_ts/email/classes.mail-egress-coordinator.js +13 -3
  15. package/dist_ts/email/reconcile-mail-dns-singleton.d.ts +1 -0
  16. package/dist_ts/email/reconcile-mail-dns-singleton.js +6 -2
  17. package/dist_ts/opsserver/handlers/dns-provider.handler.js +3 -1
  18. package/dist_ts/opsserver/handlers/dns-record.handler.js +44 -35
  19. package/dist_ts/opsserver/handlers/domain.handler.js +13 -2
  20. package/dist_ts/remoteingress/classes.hub-lifecycle.js +2 -1
  21. package/dist_ts_interfaces/data/email-domain.d.ts +2 -0
  22. package/dist_ts_web/00_commitinfo_data.js +1 -1
  23. package/package.json +3 -3
  24. package/readme.md +7 -7
  25. package/ts/00_commitinfo_data.ts +1 -1
  26. package/ts/classes.dcrouter.ts +4 -0
  27. package/ts/dns/manager.dns.ts +234 -34
  28. package/ts/dns/providers/cloudflare.provider.ts +93 -26
  29. package/ts/dns/providers/interfaces.ts +3 -0
  30. package/ts/email/classes.email-domain.manager.ts +38 -6
  31. package/ts/email/classes.mail-dns-sync.ts +246 -53
  32. package/ts/email/classes.mail-edge-eligibility.ts +53 -15
  33. package/ts/email/classes.mail-egress-coordinator.ts +18 -4
  34. package/ts/email/reconcile-mail-dns-singleton.ts +8 -1
  35. package/ts/opsserver/handlers/dns-provider.handler.ts +2 -0
  36. package/ts/opsserver/handlers/dns-record.handler.ts +58 -49
  37. package/ts/opsserver/handlers/domain.handler.ts +11 -1
  38. package/ts/remoteingress/classes.hub-lifecycle.ts +1 -0
  39. package/ts_web/00_commitinfo_data.ts +1 -1
@@ -34,6 +34,7 @@ export const MAIL_DNS_SYNC_CREATED_BY = 'mail-dns-reconciler';
34
34
  export const MAIL_DNS_MANAGED_BY = 'mail-dns-reconciler';
35
35
  export const MAIL_DNS_RETRY_INTERVAL_MS = 5 * 60_000;
36
36
  export const MAIL_DNS_MAX_RETRY_INTERVAL_MS = 60 * 60_000;
37
+ export const MAIL_DNS_RECONCILIATION_INTERVAL_MS = 5 * 60_000;
37
38
  export const MAIL_DNS_EDGE_ELIGIBILITY_DEBOUNCE_MS = 250;
38
39
  export const MAIL_DNS_EDGE_ELIGIBILITY_COOLDOWN_MS = 5_000;
39
40
  export const MAIL_DNS_PUBLIC_RESOLVER_OPTIONS = {
@@ -379,6 +380,9 @@ export class MailDnsSync {
379
380
  private dirty = false;
380
381
  private runPromise?: Promise<void>;
381
382
  private retryTimer?: ReturnType<typeof setTimeout>;
383
+ private schedulePromises = new Set<Promise<void>>();
384
+ private scheduleGeneration = 0;
385
+ private lastScheduledReconciliationAt = 0;
382
386
  private requestedGeneration = 0;
383
387
  private completedGeneration = 0;
384
388
  private forceAll = false;
@@ -416,13 +420,19 @@ export class MailDnsSync {
416
420
  try {
417
421
  await this.kick('startup');
418
422
  } catch (error: unknown) {
423
+ this.stopped = true;
424
+ this.started = false;
425
+ this.dirty = false;
426
+ this.scheduleGeneration++;
419
427
  if (this.retryTimer) clearTimeout(this.retryTimer);
420
428
  this.retryTimer = undefined;
421
429
  if (this.edgeEligibilityCheckTimer) clearTimeout(this.edgeEligibilityCheckTimer);
422
430
  this.edgeEligibilityCheckTimer = undefined;
423
431
  this.pendingEdgeEligibilityReason = undefined;
424
- this.started = false;
425
- this.dirty = false;
432
+ await this.edgeEligibilityCheckPromise?.catch(() => undefined);
433
+ await this.runPromise?.catch(() => undefined);
434
+ await Promise.all([...this.schedulePromises]);
435
+ this.verifier.destroy?.();
426
436
  throw error;
427
437
  }
428
438
  }
@@ -431,6 +441,7 @@ export class MailDnsSync {
431
441
  this.stopped = true;
432
442
  this.started = false;
433
443
  this.dirty = false;
444
+ this.scheduleGeneration++;
434
445
  if (this.retryTimer) {
435
446
  clearTimeout(this.retryTimer);
436
447
  this.retryTimer = undefined;
@@ -442,6 +453,7 @@ export class MailDnsSync {
442
453
  this.pendingEdgeEligibilityReason = undefined;
443
454
  await this.edgeEligibilityCheckPromise?.catch(() => undefined);
444
455
  await this.runPromise?.catch(() => undefined);
456
+ await Promise.all([...this.schedulePromises]);
445
457
  this.verifier.destroy?.();
446
458
  }
447
459
 
@@ -474,13 +486,14 @@ export class MailDnsSync {
474
486
  this.lastEdgeEligibilitySyncAt + this.edgeEligibilityCooldownMs - Date.now(),
475
487
  );
476
488
  const delay = Math.max(this.edgeEligibilityDebounceMs, cooldownRemaining);
477
- this.edgeEligibilityCheckTimer = setTimeout(() => {
489
+ const timer = setTimeout(() => {
478
490
  this.edgeEligibilityCheckTimer = undefined;
479
491
  this.runEdgeEligibilityCheck().catch((error: unknown) => {
480
492
  logger.log('error', `MailDnsSync edge eligibility check failed: ${(error as Error).message}`);
481
493
  });
482
- }, delay);
483
- this.edgeEligibilityCheckTimer.unref?.();
494
+ }, delay) as ReturnType<typeof setTimeout> & { unref?: () => void };
495
+ this.edgeEligibilityCheckTimer = timer;
496
+ timer.unref?.();
484
497
  }
485
498
 
486
499
  private async runEdgeEligibilityCheck(): Promise<void> {
@@ -518,8 +531,10 @@ export class MailDnsSync {
518
531
  if (this.stopped) throw new Error('MailDnsSync is stopped');
519
532
  const ticket = this.queueRequest(domainIds, domainIds === undefined);
520
533
  while (this.completedGeneration < ticket) {
534
+ if (this.stopped) throw new Error('MailDnsSync is stopped');
521
535
  await this.kick(reason);
522
536
  }
537
+ if (this.stopped) throw new Error('MailDnsSync is stopped');
523
538
  }
524
539
 
525
540
  public async syncDomain(domainId: string, reason: string): Promise<{
@@ -531,8 +546,10 @@ export class MailDnsSync {
531
546
  if (this.stopped) throw new Error('MailDnsSync is stopped');
532
547
  const requestedGeneration = this.queueRequest([domainId], false);
533
548
  while (this.completedGeneration < requestedGeneration) {
549
+ if (this.stopped) throw new Error('MailDnsSync is stopped');
534
550
  await this.kick(reason);
535
551
  }
552
+ if (this.stopped) throw new Error('MailDnsSync is stopped');
536
553
  const domain = await EmailDomainDoc.findById(domainId);
537
554
  return {
538
555
  requestedGeneration,
@@ -551,6 +568,7 @@ export class MailDnsSync {
551
568
  }
552
569
 
553
570
  private async kick(reason: string): Promise<void> {
571
+ if (this.stopped) throw new Error('MailDnsSync is stopped');
554
572
  if (this.runPromise) return await this.runPromise;
555
573
  // Defer execution one microtask so runPromise is assigned before the first
556
574
  // awaited provider/eligibility hook can synchronously enqueue another event.
@@ -561,9 +579,7 @@ export class MailDnsSync {
561
579
  logger.log('error', `MailDnsSync follow-up failed: ${(error as Error).message}`);
562
580
  });
563
581
  }
564
- this.scheduleNextDue().catch((error: unknown) => {
565
- logger.log('error', `MailDnsSync scheduling failed: ${(error as Error).message}`);
566
- });
582
+ this.queueScheduleNextDue();
567
583
  });
568
584
  return await this.runPromise;
569
585
  }
@@ -593,7 +609,15 @@ export class MailDnsSync {
593
609
  logger.log('warn', `MailDnsSync (${reason}): DnsManager unavailable; retry intent retained`);
594
610
  return;
595
611
  }
612
+ await dnsManager.runManagedMailDnsMutationExclusive(
613
+ async () => await this.reconcileExclusive(reason, work),
614
+ );
615
+ }
596
616
 
617
+ private async reconcileExclusive(
618
+ reason: string,
619
+ work: { forceAll: boolean; forcedDomainIds: Set<string> },
620
+ ): Promise<void> {
597
621
  const zones = await DomainDoc.findAll();
598
622
  const allDocs = await EmailDomainDoc.findAll();
599
623
  const now = Date.now();
@@ -663,8 +687,9 @@ export class MailDnsSync {
663
687
  new Set(plans.map((plan) => `domain:${plan.doc.id}`).concat(
664
688
  deletingDocs.map((doc) => `domain:${doc.id}`),
665
689
  )),
666
- work.forceAll,
690
+ work.forceAll || deletingDocs.length > 0,
667
691
  );
692
+ await this.pruneRetiredDkimMaterial(plans.map((plan) => plan.doc));
668
693
  await this.finalizeDeletingDomains(deletingDocs, zones);
669
694
  await this.dcRouterRef.emailDomainManager?.syncManagedDomainsToRuntime();
670
695
  await this.dcRouterRef.workAppMailManager?.applyStoredIdentitiesToRuntime();
@@ -677,9 +702,17 @@ export class MailDnsSync {
677
702
  private isDomainDue(doc: EmailDomainDoc, now: number): boolean {
678
703
  const retryAt = Date.parse(doc.reconciliation?.retryAt || '');
679
704
  if (Number.isFinite(retryAt) && retryAt <= now) return true;
705
+ if (Number.isFinite(retryAt)) return false;
680
706
  if (!doc.reconciliation?.lastAttemptAt) return true;
707
+ const lastAttemptAt = Date.parse(doc.reconciliation.lastAttemptAt);
708
+ if (
709
+ Number.isFinite(lastAttemptAt)
710
+ && lastAttemptAt + MAIL_DNS_RECONCILIATION_INTERVAL_MS <= now
711
+ ) return true;
681
712
  const rotationDueAt = this.getDkimRotationDueAt(doc, now);
682
- return rotationDueAt !== undefined && rotationDueAt <= now;
713
+ if (rotationDueAt !== undefined && rotationDueAt <= now) return true;
714
+ const retirementDueAt = this.getDkimRetirementDueAt(doc);
715
+ return retirementDueAt !== undefined && retirementDueAt <= now;
683
716
  }
684
717
 
685
718
  private getDkimRotationDueAt(doc: EmailDomainDoc, now: number): number | undefined {
@@ -702,29 +735,65 @@ export class MailDnsSync {
702
735
  return Math.max(dueAt, retryReference + MAIL_DNS_RETRY_INTERVAL_MS);
703
736
  }
704
737
 
738
+ private getDkimRetirementDueAt(doc: EmailDomainDoc): number | undefined {
739
+ const dueTimes = (doc.retiringDkim || [])
740
+ .map((material) => Date.parse(material.retireAfter || ''))
741
+ .filter(Number.isFinite);
742
+ return dueTimes.length > 0 ? Math.min(...dueTimes) : undefined;
743
+ }
744
+
705
745
  private async scheduleNextDue(): Promise<void> {
746
+ const generation = ++this.scheduleGeneration;
706
747
  if (this.retryTimer) clearTimeout(this.retryTimer);
707
748
  this.retryTimer = undefined;
708
749
  if (!this.started || this.stopped) return;
709
750
  const dueTimes: number[] = [];
710
751
  const now = Date.now();
711
- for (const doc of await EmailDomainDoc.findAll()) {
752
+ const docs = await EmailDomainDoc.findAll();
753
+ if (generation !== this.scheduleGeneration || !this.started || this.stopped) return;
754
+ for (const doc of docs) {
712
755
  const retryAt = Date.parse(doc.reconciliation?.retryAt || '');
713
- if (Number.isFinite(retryAt)) dueTimes.push(retryAt);
756
+ if (Number.isFinite(retryAt)) {
757
+ dueTimes.push(retryAt);
758
+ continue;
759
+ }
760
+ const lastAttemptAt = Date.parse(doc.reconciliation?.lastAttemptAt || '');
761
+ dueTimes.push(Number.isFinite(lastAttemptAt)
762
+ ? lastAttemptAt + MAIL_DNS_RECONCILIATION_INTERVAL_MS
763
+ : now);
714
764
  const rotationDueAt = this.getDkimRotationDueAt(doc, now);
715
765
  if (rotationDueAt !== undefined) dueTimes.push(rotationDueAt);
766
+ const retirementDueAt = this.getDkimRetirementDueAt(doc);
767
+ if (retirementDueAt !== undefined) dueTimes.push(retirementDueAt);
716
768
  }
717
769
  if (dueTimes.length === 0) return;
718
- const delay = Math.max(1, Math.min(2_147_483_647, Math.min(...dueTimes) - now));
770
+ const nextDueAt = Math.max(
771
+ Math.min(...dueTimes),
772
+ this.lastScheduledReconciliationAt + MAIL_DNS_RECONCILIATION_INTERVAL_MS,
773
+ );
774
+ const delay = Math.max(1, Math.min(2_147_483_647, nextDueAt - now));
719
775
  this.retryTimer = setTimeout(() => {
720
- if (this.stopped) return;
721
- this.queueRequest([], false);
722
- this.kick('scheduled retry').catch((error: unknown) => {
776
+ this.retryTimer = undefined;
777
+ if (this.stopped || !this.started) return;
778
+ this.lastScheduledReconciliationAt = Date.now();
779
+ this.queueRequest(undefined, true);
780
+ this.kick('scheduled desired-state reconciliation').catch((error: unknown) => {
723
781
  logger.log('error', `MailDnsSync scheduled retry failed: ${(error as Error).message}`);
724
782
  });
725
783
  }, delay);
726
784
  }
727
785
 
786
+ private queueScheduleNextDue(): void {
787
+ const schedulePromise = this.scheduleNextDue()
788
+ .catch((error: unknown) => {
789
+ logger.log('error', `MailDnsSync scheduling failed: ${(error as Error).message}`);
790
+ })
791
+ .finally(() => {
792
+ this.schedulePromises.delete(schedulePromise);
793
+ });
794
+ this.schedulePromises.add(schedulePromise);
795
+ }
796
+
728
797
  private async buildPlan(doc: EmailDomainDoc, zones: DomainDoc[]): Promise<IPlannedDomain> {
729
798
  const now = new Date().toISOString();
730
799
  const eligibility = await this.edgeEligibility.resolveForDomain(doc);
@@ -746,6 +815,12 @@ export class MailDnsSync {
746
815
  message: eligibility.reason || 'No eligible mail edge is connected',
747
816
  retryable: true,
748
817
  });
818
+ } else if (eligibility.edges.length < 2) {
819
+ preliminaryErrors.push({
820
+ code: 'INSUFFICIENT_ELIGIBLE_MAIL_EDGES',
821
+ message: 'Two eligible RemoteIngress mail edges are required; direct hub fallback is disabled',
822
+ retryable: true,
823
+ });
749
824
  }
750
825
 
751
826
  const activeDkim = this.getActiveDkim(doc);
@@ -767,19 +842,26 @@ export class MailDnsSync {
767
842
  });
768
843
  }
769
844
 
770
- const edgeIdentities = eligibility.edges.map((candidate) => this.toDomainEdgeIdentity(candidate));
771
845
  const mxSlots = this.allocateMxSlots(doc, eligibility.edges);
772
846
  if (eligibility.edges.length >= 2 && mxSlots.length < 2) {
773
847
  preliminaryErrors.push({
774
848
  code: 'INSUFFICIENT_DISTINCT_MX_IDENTITIES',
775
849
  message: 'At least two eligible mail edges exist but they do not provide two distinct MX hostnames',
776
- retryable: false,
850
+ retryable: true,
777
851
  });
778
852
  }
853
+ const candidatesById = new Map<string, IEligibleMailEdge>(
854
+ eligibility.edges.map((candidate) => [candidate.edge.id, candidate]),
855
+ );
856
+ const selectedEdges: IEligibleMailEdge[] = mxSlots.flatMap((slot) => {
857
+ const candidate = candidatesById.get(slot.edgeId);
858
+ return candidate ? [candidate] : [];
859
+ });
860
+ const edgeIdentities = selectedEdges.map((candidate) => this.toDomainEdgeIdentity(candidate));
779
861
  const revisionId = plugins.smartunique.shortId();
780
862
  const intents: IEmailDnsRecordIntent[] = [];
781
863
 
782
- for (const candidate of eligibility.edges) {
864
+ for (const candidate of selectedEdges) {
783
865
  for (const identity of candidate.identities) {
784
866
  const type = identity.family === 4 ? 'A' : 'AAAA';
785
867
  intents.push(this.intentForName(zones, {
@@ -794,7 +876,7 @@ export class MailDnsSync {
794
876
  }
795
877
  }
796
878
 
797
- if (eligibility.edges.length > 0) {
879
+ if (mxSlots.length === 2) {
798
880
  for (const slot of mxSlots) {
799
881
  intents.push(this.intentForName(zones, {
800
882
  key: `mail-domain:${doc.id}:mx:${slot.priority}:${slot.edgeId}:${slot.hostname}`,
@@ -922,13 +1004,7 @@ export class MailDnsSync {
922
1004
  }
923
1005
 
924
1006
  private allocateMxSlots(doc: EmailDomainDoc, candidates: IEligibleMailEdge[]): IEmailDomainMxSlot[] {
925
- if (candidates.length === 1) {
926
- return [{
927
- priority: 10,
928
- edgeId: candidates[0].edge.id,
929
- hostname: candidates[0].hostname,
930
- }];
931
- }
1007
+ if (candidates.length < 2) return [];
932
1008
  const byId = new Map(candidates.map((candidate) => [candidate.edge.id, candidate]));
933
1009
  const result: IEmailDomainMxSlot[] = [];
934
1010
  const usedEdges = new Set<string>();
@@ -956,7 +1032,9 @@ export class MailDnsSync {
956
1032
  usedEdges.add(candidate.edge.id);
957
1033
  usedHostnames.add(candidate.hostname);
958
1034
  }
959
- return result.sort((left, right) => left.priority - right.priority);
1035
+ return result.length === 2
1036
+ ? result.sort((left, right) => left.priority - right.priority)
1037
+ : [];
960
1038
  }
961
1039
 
962
1040
  private buildSpfValue(identities: IEmailDomainEdgeIdentity[]): string {
@@ -975,7 +1053,7 @@ export class MailDnsSync {
975
1053
  const generation = (previous?.desiredGeneration || 0) + (previousSignature === nextSignature ? 0 : 1);
976
1054
  const desiredGeneration = Math.max(generation, 1);
977
1055
  plan.revision.generation = desiredGeneration;
978
- const providerZoneIds = new Set(previous?.providerZoneIds || []);
1056
+ const providerZoneIds = new Set<string>();
979
1057
  const providerZones = new Set(zones.filter((zone) => zone.source === 'provider').map((zone) => zone.id));
980
1058
  for (const intent of plan.intents) {
981
1059
  if (providerZones.has(intent.domainId)) providerZoneIds.add(intent.domainId);
@@ -1010,6 +1088,34 @@ export class MailDnsSync {
1010
1088
  }
1011
1089
 
1012
1090
  private async persistDeletingAttempt(doc: EmailDomainDoc): Promise<void> {
1091
+ const deletingProviderRecordIds = new Set(doc.reconciliation?.deletingProviderRecordIds || []);
1092
+ const deletingRecordKeys = new Set([
1093
+ ...(doc.reconciliation?.activeRevision?.recordKeys || []),
1094
+ ...(doc.reconciliation?.pendingRevision?.recordKeys || []),
1095
+ ]);
1096
+ const retainedTopologyKeys = new Set<string>();
1097
+ for (const otherDoc of await EmailDomainDoc.findAll()) {
1098
+ if (otherDoc.id === doc.id || otherDoc.reconciliation?.lifecycleStatus === 'deleting') continue;
1099
+ for (const intent of otherDoc.reconciliation?.intents || []) retainedTopologyKeys.add(intent.key);
1100
+ for (const key of otherDoc.reconciliation?.activeRevision?.recordKeys || []) retainedTopologyKeys.add(key);
1101
+ for (const key of otherDoc.reconciliation?.pendingRevision?.recordKeys || []) retainedTopologyKeys.add(key);
1102
+ }
1103
+ const hasKnownProviderProvenance = doc.reconciliation?.providerZoneIds !== undefined;
1104
+ const providerZoneIds = new Set(doc.reconciliation?.providerZoneIds || []);
1105
+ for (const record of await DnsRecordDoc.findAll()) {
1106
+ const ownedByDomain = record.managedOwnerId === `domain:${doc.id}`;
1107
+ const unsharedDeletingTopology = record.managedOwnerId?.startsWith('topology:')
1108
+ && Boolean(record.managedRecordKey && deletingRecordKeys.has(record.managedRecordKey))
1109
+ && Boolean(record.managedRecordKey && !retainedTopologyKeys.has(record.managedRecordKey));
1110
+ if (
1111
+ record.managedBy === MAIL_DNS_MANAGED_BY
1112
+ && (ownedByDomain || unsharedDeletingTopology)
1113
+ && record.providerRecordId?.trim()
1114
+ ) {
1115
+ deletingProviderRecordIds.add(record.providerRecordId.trim());
1116
+ if (hasKnownProviderProvenance) providerZoneIds.add(record.domainId);
1117
+ }
1118
+ }
1013
1119
  doc.reconciliation = {
1014
1120
  ...(doc.reconciliation || {
1015
1121
  lifecycleStatus: 'deleting',
@@ -1022,6 +1128,8 @@ export class MailDnsSync {
1022
1128
  errors: (doc.reconciliation?.errors || []).filter((error) => error.code !== 'DNS_CLEANUP_FAILED'),
1023
1129
  lastAttemptAt: new Date().toISOString(),
1024
1130
  retryAt: new Date(Date.now() + MAIL_DNS_RETRY_INTERVAL_MS).toISOString(),
1131
+ ...(hasKnownProviderProvenance ? { providerZoneIds: [...providerZoneIds].sort() } : {}),
1132
+ deletingProviderRecordIds: [...deletingProviderRecordIds].sort(),
1025
1133
  };
1026
1134
  await doc.save();
1027
1135
  }
@@ -1095,16 +1203,35 @@ export class MailDnsSync {
1095
1203
  const rrset = records.filter((record) => (
1096
1204
  record.name.toLowerCase() === intent.name.toLowerCase() && record.type === intent.type
1097
1205
  ));
1206
+ const ownedByKey = records.filter((record) => (
1207
+ record.managedBy === MAIL_DNS_MANAGED_BY
1208
+ && record.managedOwnerId === intent.ownerId
1209
+ && record.managedRecordKey === intent.key
1210
+ ));
1211
+ if (ownedByKey.length > 1) {
1212
+ intent.status = 'conflict';
1213
+ intent.verificationOutcome = 'duplicate';
1214
+ intent.error = {
1215
+ code: 'DNS_MANAGED_KEY_DUPLICATE',
1216
+ message: `Multiple managed DNS records share ownership key ${intent.key}`,
1217
+ recordKey: intent.key,
1218
+ retryable: false,
1219
+ };
1220
+ return;
1221
+ }
1222
+ const reconciliationRecords = [...new Map(
1223
+ [...rrset, ...ownedByKey].map((record) => [record.id, record]),
1224
+ ).values()];
1098
1225
  if (await reconcileMailDnsSingleton({
1099
1226
  intent,
1100
- rrset,
1227
+ rrset: reconciliationRecords,
1101
1228
  dnsManager: this.dcRouterRef.dnsManager!,
1102
1229
  managedBy: MAIL_DNS_MANAGED_BY,
1103
1230
  createdBy: MAIL_DNS_SYNC_CREATED_BY,
1104
1231
  evaluate: async (candidate, values) => await this.evaluateTxtRecords(candidate, values),
1105
1232
  })) return;
1106
1233
 
1107
- const exactOwner = rrset
1234
+ const exactOwner = reconciliationRecords
1108
1235
  .filter((record) => (
1109
1236
  record.managedBy === MAIL_DNS_MANAGED_BY
1110
1237
  && record.managedOwnerId === intent.ownerId
@@ -1175,10 +1302,16 @@ export class MailDnsSync {
1175
1302
  const managed = exactOwner.find((record) => this.valuesEqual(intent.type, record.value, intent.value))
1176
1303
  || exactOwner[0];
1177
1304
  if (managed) {
1178
- if (!this.valuesEqual(intent.type, managed.value, intent.value) || managed.name !== intent.name) {
1305
+ if (
1306
+ !this.valuesEqual(intent.type, managed.value, intent.value)
1307
+ || managed.name !== intent.name
1308
+ || managed.type !== intent.type
1309
+ || managed.ttl !== intent.ttl
1310
+ ) {
1179
1311
  const result = await this.dcRouterRef.dnsManager!.updateRecord({
1180
1312
  id: managed.id,
1181
1313
  name: intent.name,
1314
+ type: intent.type,
1182
1315
  value: intent.value,
1183
1316
  ttl: intent.ttl,
1184
1317
  });
@@ -1347,10 +1480,8 @@ export class MailDnsSync {
1347
1480
  }
1348
1481
 
1349
1482
  const canActivate = plan.egressIdentitySupported
1350
- && plan.revision.edgeIdentities.length > 0
1351
- && (plan.revision.edgeIdentities.length >= 2
1352
- ? plan.revision.mxSlots.length === 2
1353
- : plan.revision.mxSlots.length === 1)
1483
+ && plan.revision.edgeIdentities.length === 2
1484
+ && plan.revision.mxSlots.length === 2
1354
1485
  && plan.selectorCorrectSigningSupported
1355
1486
  && Boolean(this.getActiveDkim(plan.doc))
1356
1487
  && plan.intents.length > 0
@@ -1415,8 +1546,8 @@ export class MailDnsSync {
1415
1546
  : { retryAt: new Date(Date.now() + retryDelay).toISOString() }),
1416
1547
  };
1417
1548
  const mailPublicationReady = plan.egressIdentitySupported
1418
- && plan.revision.edgeIdentities.length > 0
1419
- && plan.revision.mxSlots.length > 0;
1549
+ && plan.revision.edgeIdentities.length === 2
1550
+ && plan.revision.mxSlots.length === 2;
1420
1551
  plan.doc.dnsStatus = {
1421
1552
  mx: mailPublicationReady
1422
1553
  ? projectMailDnsIntentAggregateStatus(plan.intents, (intent) => intent.type === 'MX')
@@ -1523,45 +1654,93 @@ export class MailDnsSync {
1523
1654
  for (const doc of docs) {
1524
1655
  if (doc.reconciliation?.lifecycleStatus === 'deleting') continue;
1525
1656
  for (const intent of doc.reconciliation?.intents || []) keep.add(intent.key);
1526
- if (doc.reconciliation?.lifecycleStatus !== 'active') {
1657
+ const hasCompleteDesiredMxSet = (doc.reconciliation?.intents || [])
1658
+ .filter((intent) => intent.type === 'MX').length === 2;
1659
+ if (doc.reconciliation?.lifecycleStatus !== 'active' && hasCompleteDesiredMxSet) {
1527
1660
  for (const key of doc.reconciliation?.activeRevision?.recordKeys || []) keep.add(key);
1528
1661
  }
1529
1662
  }
1530
1663
  const managed = (await DnsRecordDoc.findAll()).filter((record) => record.managedBy === MAIL_DNS_MANAGED_BY);
1531
- for (const record of managed) {
1532
- if (!record.managedOwnerId || !record.managedRecordKey) continue;
1664
+ const staleRecords = managed.filter((record) => {
1665
+ if (!record.managedOwnerId || !record.managedRecordKey) return false;
1533
1666
  if (
1534
1667
  !selectedOwnerIds.has(record.managedOwnerId)
1535
1668
  && !(allowTopologyCleanup && record.managedOwnerId.startsWith('topology:'))
1536
- ) continue;
1537
- if (record.managedRecordKey && keep.has(record.managedRecordKey)) continue;
1669
+ ) return false;
1670
+ if (keep.has(record.managedRecordKey)) return false;
1538
1671
  const zone = zones.find((candidate) => candidate.id === record.domainId);
1539
- if (zone?.source === 'provider' && !provenProviderZoneIds.has(zone.id)) continue;
1540
- try {
1541
- const result = await this.dcRouterRef.dnsManager!.deleteRecord(record.id);
1542
- if (!result.success) throw new Error(result.message || 'delete failed');
1543
- } catch (error: unknown) {
1544
- const ownerId = record.managedOwnerId?.replace(/^domain:/, '');
1545
- const owner = ownerId ? docs.find((doc) => doc.id === ownerId) : undefined;
1546
- if (owner?.reconciliation) {
1672
+ return zone?.source !== 'provider' || provenProviderZoneIds.has(zone.id);
1673
+ });
1674
+ if (staleRecords.length === 0) return;
1675
+ const results = await this.dcRouterRef.dnsManager!.deleteRecords(
1676
+ staleRecords.map((record) => record.id),
1677
+ );
1678
+ for (let index = 0; index < staleRecords.length; index++) {
1679
+ const record = staleRecords[index];
1680
+ const result = results[index];
1681
+ if (result?.success) continue;
1682
+ const error = new Error(result?.message || 'delete failed');
1683
+ const domainOwnerId = record.managedOwnerId?.startsWith('domain:')
1684
+ ? record.managedOwnerId.slice('domain:'.length)
1685
+ : undefined;
1686
+ const affectedOwners = domainOwnerId
1687
+ ? docs.filter((doc) => doc.id === domainOwnerId)
1688
+ : docs.filter((doc) => selectedOwnerIds.has(`domain:${doc.id}`));
1689
+ if (affectedOwners.length > 0) {
1690
+ for (const owner of affectedOwners) {
1691
+ if (!owner.reconciliation) continue;
1547
1692
  owner.reconciliation.errors.push({
1548
1693
  code: 'DNS_CLEANUP_FAILED',
1549
- message: `Failed to delete ${record.type} ${record.name}: ${(error as Error).message}`,
1694
+ message: `Failed to delete ${record.type} ${record.name}: ${error.message}`,
1550
1695
  recordKey: record.managedRecordKey,
1551
1696
  retryable: true,
1552
1697
  });
1553
1698
  owner.reconciliation.retryAt = new Date(Date.now() + MAIL_DNS_RETRY_INTERVAL_MS).toISOString();
1699
+ if (owner.reconciliation.lifecycleStatus !== 'deleting') {
1700
+ owner.reconciliation.lifecycleStatus = 'pending';
1701
+ }
1554
1702
  await owner.save();
1555
1703
  }
1704
+ } else {
1705
+ logger.log(
1706
+ 'error',
1707
+ `MailDnsSync failed to delete orphaned topology record ${record.type} ${record.name}: ${error.message}`,
1708
+ );
1556
1709
  }
1557
1710
  }
1558
1711
  }
1559
1712
 
1713
+ private async pruneRetiredDkimMaterial(docs: EmailDomainDoc[]): Promise<void> {
1714
+ const now = Date.now();
1715
+ const records = await DnsRecordDoc.findAll();
1716
+ for (const plannedDoc of docs) {
1717
+ const doc = await EmailDomainDoc.findById(plannedDoc.id);
1718
+ if (!doc) continue;
1719
+ const retiring = doc.retiringDkim || [];
1720
+ const retained = retiring.filter((material) => {
1721
+ const retireAt = Date.parse(material.retireAfter || '');
1722
+ if (!Number.isFinite(retireAt) || retireAt > now) return true;
1723
+ const key = `mail-domain:${doc.id}:dkim:${material.selector}`;
1724
+ return records.some((record) => (
1725
+ record.managedBy === MAIL_DNS_MANAGED_BY
1726
+ && record.managedOwnerId === `domain:${doc.id}`
1727
+ && record.managedRecordKey === key
1728
+ ));
1729
+ });
1730
+ if (retained.length === retiring.length) continue;
1731
+ doc.retiringDkim = retained;
1732
+ doc.updatedAt = new Date().toISOString();
1733
+ await doc.save();
1734
+ }
1735
+ }
1736
+
1560
1737
  private async finalizeDeletingDomains(
1561
1738
  deletingDocs: EmailDomainDoc[],
1562
1739
  zones: DomainDoc[],
1563
1740
  ): Promise<void> {
1564
- for (const doc of deletingDocs) {
1741
+ for (const deletingDoc of deletingDocs) {
1742
+ const doc = await EmailDomainDoc.findById(deletingDoc.id);
1743
+ if (!doc) continue;
1565
1744
  if (doc.reconciliation?.lifecycleStatus !== 'deleting') continue;
1566
1745
  if (doc.reconciliation.providerZoneIds === undefined) continue;
1567
1746
  if (doc.reconciliation.errors.some((error) => (
@@ -1587,6 +1766,20 @@ export class MailDnsSync {
1587
1766
  await doc.save();
1588
1767
  break;
1589
1768
  }
1769
+ const stillPublishedIds = new Set(confirmation.listedProviderRecordIds || []);
1770
+ const lingeringProviderId = (doc.reconciliation.deletingProviderRecordIds || [])
1771
+ .find((providerRecordId) => stillPublishedIds.has(providerRecordId));
1772
+ if (lingeringProviderId) {
1773
+ providerProofComplete = false;
1774
+ doc.reconciliation.errors.push({
1775
+ code: 'DNS_CLEANUP_FAILED',
1776
+ message: `Provider zone ${zone.name} still publishes deleting record ${lingeringProviderId}`,
1777
+ retryable: true,
1778
+ });
1779
+ doc.reconciliation.retryAt = new Date(Date.now() + MAIL_DNS_RETRY_INTERVAL_MS).toISOString();
1780
+ await doc.save();
1781
+ break;
1782
+ }
1590
1783
  }
1591
1784
  if (!providerProofComplete) continue;
1592
1785
  const records = await DnsRecordDoc.findAll();
@@ -2,6 +2,7 @@ import { REMOTE_INGRESS_MAIL_TAG } from '../../ts_interfaces/data/remoteingress.
2
2
  import type { IRemoteIngress } from '../../ts_interfaces/data/remoteingress.js';
3
3
  import type { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
4
4
  import type { DcRouter } from '../classes.dcrouter.js';
5
+ import type { IEmailDomainEdgeIdentity } from '../../ts_interfaces/data/email-domain.js';
5
6
 
6
7
  export const MAIL_EGRESS_IDENTITY_MAX_AGE_MS = 45_000;
7
8
 
@@ -146,13 +147,22 @@ export class MailEdgeEligibility {
146
147
  const hostname = edge.mailHostname?.toLowerCase().replace(/\.$/, '');
147
148
  if (!hostname) continue;
148
149
 
149
- const identities = (identitiesByEdge.get(edge.id) || []).filter((identity) => {
150
- if (identity.heloHostname.toLowerCase().replace(/\.$/, '') !== hostname) return false;
151
- return identity.family === 4
152
- ? Boolean(edge.publicIp && edge.publicIp === identity.address)
153
- : Boolean(edge.publicIpV6 && edge.publicIpV6 === identity.address);
150
+ const reported = (identitiesByEdge.get(edge.id) || []).filter((identity) => (
151
+ identity.heloHostname.toLowerCase().replace(/\.$/, '') === hostname
152
+ ));
153
+ const expected = [
154
+ ...(edge.publicIp ? [{ family: 4 as const, address: edge.publicIp }] : []),
155
+ ...(edge.publicIpV6 ? [{ family: 6 as const, address: edge.publicIpV6 }] : []),
156
+ ];
157
+ const identities = expected.flatMap((configured) => {
158
+ const matches = reported.filter((identity) => (
159
+ identity.family === configured.family && identity.address === configured.address
160
+ ));
161
+ return matches.length === 1 ? matches : [];
154
162
  });
155
- if (identities.length > 0) {
163
+ // Every configured source family must have one fresh source-bound proof.
164
+ // A matching IPv4 observation never excuses an unproven configured IPv6.
165
+ if (expected.length > 0 && identities.length === expected.length) {
156
166
  edges.push({ edge, hostname, identities });
157
167
  }
158
168
  }
@@ -179,27 +189,55 @@ export class MailEdgeEligibility {
179
189
 
180
190
  public async resolveActiveForDomain(doc: EmailDomainDoc): Promise<IMailEdgeEligibilityResult> {
181
191
  const live = await this.resolveForDomain(doc);
182
- const active = doc.reconciliation?.activeRevision;
183
- if (!live.supported || !active) {
192
+ const reconciliation = doc.reconciliation;
193
+ const active = reconciliation?.activeRevision;
194
+ const slots = active?.mxSlots || [];
195
+ const completeSlots = slots.length === 2
196
+ && slots[0]?.priority === 10
197
+ && slots[1]?.priority === 20
198
+ && slots[0].edgeId !== slots[1].edgeId
199
+ && slots[0].hostname !== slots[1].hostname;
200
+ if (
201
+ !live.supported
202
+ || reconciliation?.lifecycleStatus !== 'active'
203
+ || reconciliation.errors.length > 0
204
+ || !active
205
+ || active.generation !== reconciliation.desiredGeneration
206
+ || !completeSlots
207
+ ) {
184
208
  return {
185
209
  supported: live.supported,
186
- reason: live.reason || 'email domain has no validated active DNS revision',
210
+ reason: live.reason || 'email domain has no current complete validated DNS revision',
187
211
  edges: [],
188
212
  };
189
213
  }
190
- const activeByEdge = new Map(active.edgeIdentities.map((identity) => [identity.edgeId, identity]));
191
- const edges = live.edges.filter((candidate) => {
214
+ const activeByEdge = new Map<string, IEmailDomainEdgeIdentity>(
215
+ active.edgeIdentities.map((identity) => [identity.edgeId, identity]),
216
+ );
217
+ const liveByEdge = new Map(live.edges.map((candidate) => [candidate.edge.id, candidate]));
218
+ const edges = slots.flatMap((slot) => {
219
+ const candidate = liveByEdge.get(slot.edgeId);
220
+ if (!candidate || candidate.hostname !== slot.hostname) return [];
192
221
  const activeIdentity = activeByEdge.get(candidate.edge.id);
193
- if (!activeIdentity || activeIdentity.hostname !== candidate.hostname) return false;
194
- return candidate.identities.length > 0 && candidate.identities.every((identity) => (
222
+ if (!activeIdentity || activeIdentity.hostname !== candidate.hostname) return [];
223
+ const expectedFamilies = [
224
+ ...(activeIdentity.ipv4 ? [{ family: 4 as const, address: activeIdentity.ipv4 }] : []),
225
+ ...(activeIdentity.ipv6 ? [{ family: 6 as const, address: activeIdentity.ipv6 }] : []),
226
+ ];
227
+ const exact = candidate.identities.length === expectedFamilies.length
228
+ && expectedFamilies.every((expected) => candidate.identities.some((identity) => (
229
+ identity.family === expected.family && identity.address === expected.address
230
+ )))
231
+ && candidate.identities.every((identity) => (
195
232
  (identity.family === 4 && identity.address === activeIdentity.ipv4)
196
233
  || (identity.family === 6 && identity.address === activeIdentity.ipv6)
197
234
  ));
235
+ return exact ? [candidate] : [];
198
236
  });
199
237
  return {
200
238
  supported: live.supported,
201
- ...(edges.length === 0 ? { reason: 'no live edge is present in the validated active DNS revision' } : {}),
202
- edges,
239
+ ...(edges.length !== 2 ? { reason: 'both validated MX edges must be live with exact source-bound identities' } : {}),
240
+ edges: edges.length === 2 ? edges : [],
203
241
  };
204
242
  }
205
243
  }