@serve.zone/dcrouter 17.1.3 → 17.1.5

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.
@@ -3,7 +3,7 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '17.1.3',
6
+ version: '17.1.5',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  };
9
9
  //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vdHNfd2ViLzAwX2NvbW1pdGluZm9fZGF0YS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7R0FFRztBQUNILE1BQU0sQ0FBQyxNQUFNLFVBQVUsR0FBRztJQUN4QixJQUFJLEVBQUUsc0JBQXNCO0lBQzVCLE9BQU8sRUFBRSxRQUFRO0lBQ2pCLFdBQVcsRUFBRSwwRUFBMEU7Q0FDeEYsQ0FBQSJ9
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@serve.zone/dcrouter",
3
3
  "private": false,
4
- "version": "17.1.3",
4
+ "version": "17.1.5",
5
5
  "description": "A multifaceted routing service handling mail and SMS delivery functions.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -40,7 +40,7 @@
40
40
  "@push.rocks/smartbucket": "^4.6.1",
41
41
  "@push.rocks/smartchallenge": "^1.1.0",
42
42
  "@push.rocks/smartdata": "^7.3.1",
43
- "@push.rocks/smartdb": "^2.13.1",
43
+ "@push.rocks/smartdb": "^2.13.2",
44
44
  "@push.rocks/smartdns": "^7.12.1",
45
45
  "@push.rocks/smartfs": "^1.6.0",
46
46
  "@push.rocks/smartguard": "^3.1.2",
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '17.1.3',
6
+ version: '17.1.5',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -816,10 +816,17 @@ export class DcRouter {
816
816
  // Mail DNS reconciliation starts last: it needs persistence, provider
817
817
  // state, the SMTP DKIM creator, and live tunnel topology. Earlier events
818
818
  // only mark the reconciler dirty and are consumed here.
819
+ const mailDnsSyncDependencies = [
820
+ 'DnsManager', 'EmailDomainManager', 'EmailServer', 'RemoteIngress',
821
+ ];
822
+ if (this.options.dnsNsDomains?.length && this.options.dnsScopes?.length) {
823
+ mailDnsSyncDependencies.push('DnsServer');
824
+ }
825
+
819
826
  this.serviceManager.addService(
820
827
  new plugins.taskbuffer.Service('MailDnsSync')
821
828
  .optional()
822
- .dependsOn('DnsManager', 'EmailDomainManager', 'EmailServer', 'RemoteIngress')
829
+ .dependsOn(...mailDnsSyncDependencies)
823
830
  .withStart(async () => {
824
831
  await this.mailDnsSync.start();
825
832
  })
@@ -1,6 +1,9 @@
1
1
  import * as plugins from '../../plugins.js';
2
2
  import { DcRouterDb } from '../classes.dcrouter-db.js';
3
- import type { IIpIntelligenceRecord } from '../../../ts_interfaces/data/security-policy.js';
3
+ import type {
4
+ IIpIntelligenceRecord,
5
+ TSecurityBlockRuleMatchMode,
6
+ } from '../../../ts_interfaces/data/security-policy.js';
4
7
 
5
8
  const getDb = () => DcRouterDb.getInstance().getDb();
6
9
 
@@ -72,7 +75,106 @@ export class IpIntelligenceDoc extends plugins.smartdata.SmartDataDbDoc<IpIntell
72
75
  return await IpIntelligenceDoc.getInstance({ ipAddress });
73
76
  }
74
77
 
78
+ public static async findByAsn(asn: number): Promise<IpIntelligenceDoc[]> {
79
+ return await IpIntelligenceDoc.getInstances({ asn });
80
+ }
81
+
82
+ public static async findByOrganization(
83
+ organization: string,
84
+ matchMode: TSecurityBlockRuleMatchMode,
85
+ ): Promise<IpIntelligenceDoc[]> {
86
+ const normalizedOrganization = organization.trim();
87
+ if (!normalizedOrganization) return [];
88
+
89
+ const escapedOrganization = normalizedOrganization.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
90
+ const regex = matchMode === 'exact'
91
+ ? `^${escapedOrganization}$`
92
+ : escapedOrganization;
93
+
94
+ return await IpIntelligenceDoc.getInstances({
95
+ $or: [
96
+ { asnOrg: { $regex: regex, $options: 'i' } },
97
+ { registrantOrg: { $regex: regex, $options: 'i' } },
98
+ ],
99
+ });
100
+ }
101
+
75
102
  public static async findAll(): Promise<IpIntelligenceDoc[]> {
76
103
  return await IpIntelligenceDoc.getInstances({});
77
104
  }
105
+
106
+ public static async findRecent(options: {
107
+ ipAddresses?: string[];
108
+ limit: number;
109
+ }): Promise<IpIntelligenceDoc[]> {
110
+ if (!Number.isSafeInteger(options.limit) || options.limit <= 0) {
111
+ throw new Error('IpIntelligenceDoc.findRecent requires a positive integer limit');
112
+ }
113
+
114
+ const filter = options.ipAddresses
115
+ ? { ipAddress: { $in: options.ipAddresses } }
116
+ : {};
117
+ const collection = (IpIntelligenceDoc as typeof IpIntelligenceDoc & {
118
+ collection: plugins.smartdata.SmartdataCollection<IpIntelligenceDoc>;
119
+ }).collection;
120
+ const cursor = await collection.getCursor(
121
+ filter,
122
+ IpIntelligenceDoc as unknown as typeof plugins.smartdata.SmartDataDbDoc,
123
+ );
124
+ cursor.mongodbCursor.sort({ lastSeenAt: -1, _id: -1 });
125
+ cursor.mongodbCursor.limit(options.limit);
126
+ try {
127
+ return await cursor.toArray();
128
+ } finally {
129
+ await cursor.close();
130
+ }
131
+ }
132
+
133
+ public static async countAll(): Promise<number> {
134
+ return await DcRouterDb.getInstance()
135
+ .getDb()
136
+ .mongoDb
137
+ .collection('IpIntelligenceDoc')
138
+ .countDocuments({});
139
+ }
140
+
141
+ public static async findOldestIds(options: {
142
+ lastSeenBefore?: number;
143
+ limit: number;
144
+ }): Promise<unknown[]> {
145
+ if (!Number.isSafeInteger(options.limit) || options.limit <= 0) {
146
+ throw new Error('IpIntelligenceDoc.findOldestIds requires a positive integer limit');
147
+ }
148
+ const collection = DcRouterDb.getInstance()
149
+ .getDb()
150
+ .mongoDb
151
+ .collection('IpIntelligenceDoc');
152
+ const filter = options.lastSeenBefore === undefined
153
+ ? {}
154
+ : { lastSeenAt: { $lt: options.lastSeenBefore } };
155
+ const cursor = collection
156
+ .find(filter, { projection: { _id: 1 } })
157
+ .sort({ lastSeenAt: 1, _id: 1 })
158
+ .limit(options.limit);
159
+ try {
160
+ const rows = await cursor.toArray();
161
+ return rows.map((row) => row._id);
162
+ } finally {
163
+ await cursor.close();
164
+ }
165
+ }
166
+
167
+ public static async deleteByDocumentIds(documentIds: unknown[]): Promise<number> {
168
+ if (documentIds.length === 0) return 0;
169
+ if (documentIds.length > 500 || documentIds.some((id) => id === undefined || id === null)) {
170
+ throw new Error('IpIntelligenceDoc.deleteByDocumentIds requires 1 to 500 document ids');
171
+ }
172
+ const deleteFilter: Record<string, any> = { _id: { $in: documentIds } };
173
+ const result = await DcRouterDb.getInstance()
174
+ .getDb()
175
+ .mongoDb
176
+ .collection('IpIntelligenceDoc')
177
+ .deleteMany(deleteFilter);
178
+ return result.deletedCount;
179
+ }
78
180
  }
@@ -150,9 +150,7 @@ export class DnsServerRuntime {
150
150
 
151
151
  // Hand the DnsServer to DnsManager so DB-backed local records on
152
152
  // dcrouter-hosted domains get registered too.
153
- if (this.dcRouterRef.dnsManager) {
154
- await this.dcRouterRef.dnsManager.attachDnsServer(dnsServer);
155
- }
153
+ await this.attachDnsServer(dnsServer);
156
154
  } catch (error) {
157
155
  dnsServer.removeAllListeners();
158
156
  await dnsServer.stop().catch((stopError: Error) => {
@@ -166,6 +164,14 @@ export class DnsServerRuntime {
166
164
  }
167
165
  }
168
166
 
167
+ private async attachDnsServer(
168
+ dnsServer: plugins.smartdns.dnsServerMod.DnsServer,
169
+ ): Promise<void> {
170
+ if (!this.dcRouterRef.dnsManager) return;
171
+ await this.dcRouterRef.dnsManager.attachDnsServer(dnsServer);
172
+ this.dcRouterRef.mailDnsSync?.requestSync('DNS server attached');
173
+ }
174
+
169
175
  /**
170
176
  * Create the DoH socket handler SmartProxy routes hand TLS sockets to.
171
177
  */
@@ -16,6 +16,7 @@ import {
16
16
  MAIL_DNS_MAX_RETRY_INTERVAL_MS,
17
17
  MAIL_DNS_RETRY_INTERVAL_MS,
18
18
  } from './classes.mail-dns-sync.js';
19
+ import { projectMailDnsIntentStatus } from './mail-dns-status.js';
19
20
 
20
21
  export interface IEmailDomainManagerActionResult {
21
22
  success: boolean;
@@ -325,33 +326,39 @@ export class EmailDomainManager {
325
326
  type: intent.type,
326
327
  name: intent.name,
327
328
  value: intent.value,
328
- status: intent.status === 'valid'
329
- ? 'valid'
330
- : intent.status === 'conflict' || intent.status === 'failed'
331
- ? 'invalid'
332
- : 'missing',
329
+ status: projectMailDnsIntentStatus(intent),
333
330
  }));
334
331
  }
335
332
 
336
333
  public async provisionDnsRecords(id: string): Promise<IEmailDomainManagerActionResult> {
337
- const before = new Set(
338
- (await DnsRecordDoc.findAll()).filter((record) => record.managedBy === 'mail-dns-reconciler').map((record) => record.id),
339
- );
340
- await this.dcRouter.mailDnsSync?.sync(`manual provision: ${id}`);
341
- const doc = await EmailDomainDoc.findById(id);
334
+ const current = await EmailDomainDoc.findById(id);
335
+ if (!current) throw new Error(`Email domain not found: ${id}`);
336
+ const before = new Set((await DnsRecordDoc.findByDomainId(current.linkedDomainId))
337
+ .filter((record) => record.managedBy === 'mail-dns-reconciler').map((record) => record.id));
338
+ const mailDnsSync = this.dcRouter.mailDnsSync;
339
+ if (!mailDnsSync) throw new Error('MailDnsSync is unavailable');
340
+ const ticket = await mailDnsSync.syncDomain(id, `manual provision: ${id}`);
341
+ const doc = ticket.domain;
342
342
  if (!doc) throw new Error(`Email domain not found: ${id}`);
343
- const provisioned = (await DnsRecordDoc.findAll())
343
+ const provisioned = ticket.records
344
344
  .filter((record) => record.managedBy === 'mail-dns-reconciler' && !before.has(record.id)).length;
345
- return { ...this.resultForDoc(doc, !this.hasDnsMutationFailure(doc)), provisioned };
345
+ return { ...this.resultForDoc(doc, true), provisioned };
346
346
  }
347
347
 
348
348
  public async validateDns(id: string): Promise<IEmailDomainManagerActionResult> {
349
- await this.dcRouter.mailDnsSync?.sync(`manual validation: ${id}`);
350
- const doc = await EmailDomainDoc.findById(id);
349
+ const mailDnsSync = this.dcRouter.mailDnsSync;
350
+ if (!mailDnsSync) throw new Error('MailDnsSync is unavailable');
351
+ const ticket = await mailDnsSync.syncDomain(id, `manual validation: ${id}`);
352
+ const doc = ticket.domain;
351
353
  if (!doc) throw new Error(`Email domain not found: ${id}`);
352
- const records = await this.getRequiredDnsRecords(id);
354
+ const records = (doc.reconciliation?.intents || []).map((intent) => ({
355
+ type: intent.type,
356
+ name: intent.name,
357
+ value: intent.value,
358
+ status: projectMailDnsIntentStatus(intent),
359
+ }));
353
360
  return {
354
- ...this.resultForDoc(doc, doc.reconciliation?.lifecycleStatus === 'active'),
361
+ ...this.resultForDoc(doc, true),
355
362
  domain: this.docToInterface(doc),
356
363
  records,
357
364
  };
@@ -27,14 +27,15 @@ import {
27
27
  applyMailDnsVerificationResult,
28
28
  reconcileMailDnsSingleton,
29
29
  } from './reconcile-mail-dns-singleton.js';
30
+ import { projectMailDnsIntentAggregateStatus } from './mail-dns-status.js';
30
31
 
31
32
  export const MAIL_DNS_SYNC_CREATED_BY = 'mail-dns-reconciler';
32
33
  export const MAIL_DNS_MANAGED_BY = 'mail-dns-reconciler';
33
34
  export const MAIL_DNS_RETRY_INTERVAL_MS = 5 * 60_000;
34
35
  export const MAIL_DNS_MAX_RETRY_INTERVAL_MS = 60 * 60_000;
35
36
  export const MAIL_DNS_PUBLIC_RESOLVER_OPTIONS = {
36
- strategy: 'doh' as const,
37
- allowDohFallback: false,
37
+ strategy: 'prefer-udp' as const,
38
+ allowDohFallback: true,
38
39
  timeoutMs: 5_000,
39
40
  };
40
41
 
@@ -175,7 +176,25 @@ export class SmartDnsMailVerifier implements IMailDnsVerifier {
175
176
  };
176
177
  }
177
178
  if (result.status === 'missing') {
178
- return { status: 'missing', reason: result.missingReason };
179
+ const evidence = result.attempts.map((attempt) => {
180
+ if (attempt.status === 'missing') {
181
+ return `${attempt.transport}:missing:${attempt.missingReason}`;
182
+ }
183
+ if (attempt.status === 'error') {
184
+ return `${attempt.transport}:error:${attempt.error.code}`;
185
+ }
186
+ return `${attempt.transport}:success`;
187
+ }).join(', ');
188
+ const corroboratedMissing = (
189
+ result.attempts.length === 2
190
+ && result.attempts[0].transport === 'udp'
191
+ && result.attempts[0].status === 'missing'
192
+ && result.attempts[1].transport === 'doh'
193
+ && result.attempts[1].status === 'missing'
194
+ );
195
+ return corroboratedMissing
196
+ ? { status: 'missing', reason: `${result.missingReason}; attempts=${evidence}` }
197
+ : { status: 'error', reason: `unconfirmed ${result.missingReason}; attempts=${evidence || 'none'}` };
179
198
  }
180
199
  return {
181
200
  status: 'error',
@@ -190,7 +209,7 @@ export class SmartDnsMailVerifier implements IMailDnsVerifier {
190
209
  return { valid: false, outcome: 'lookup-error', reason: `DNS lookup failed: ${query.reason}` };
191
210
  }
192
211
  if (query.status === 'missing') {
193
- return { valid: false, outcome: 'missing', reason: `${intent.type} ${intent.name} is missing` };
212
+ return { valid: false, outcome: 'missing', reason: `${intent.type} ${intent.name} is missing (${query.reason})` };
194
213
  }
195
214
  const values = query.values;
196
215
  if (intent.type === 'MX') {
@@ -255,7 +274,7 @@ export class SmartDnsMailVerifier implements IMailDnsVerifier {
255
274
  return { valid: false, outcome: 'lookup-error', reason: `MX lookup failed: ${query.reason}` };
256
275
  }
257
276
  if (query.status === 'missing') {
258
- return { valid: false, outcome: 'missing', reason: `MX ${intents[0].name} is missing` };
277
+ return { valid: false, outcome: 'missing', reason: `MX ${intents[0].name} is missing (${query.reason})` };
259
278
  }
260
279
  return evaluateMailMxValues(intents, query.values, previousValues);
261
280
  } catch (error: unknown) {
@@ -319,6 +338,12 @@ interface IPlannedDomain {
319
338
  selectorCorrectSigningSupported: boolean;
320
339
  }
321
340
 
341
+ interface IZoneReconciliationResult {
342
+ errors: Map<string, IEmailDomainOperationError>;
343
+ provenProviderZoneIds: Set<string>;
344
+ readyZoneIds: Set<string>;
345
+ }
346
+
322
347
  /**
323
348
  * Sole owner of managed mail DNS. API, topology, timer, and startup events only
324
349
  * mark this globally serialized reconciler dirty.
@@ -520,7 +545,7 @@ export class MailDnsSync {
520
545
  if (outcome) Object.assign(intent, outcome);
521
546
  if (intent.error) errors.push(intent.error);
522
547
  }
523
- await this.validateAndActivate(plan, errors, zoneResult.errors);
548
+ await this.validateAndActivate(plan, errors, zoneResult, zones);
524
549
  }
525
550
 
526
551
  const cleanupProof = await this.refreshProviderZonesForCleanup(
@@ -900,12 +925,10 @@ export class MailDnsSync {
900
925
  private async reconcileZones(
901
926
  intents: IEmailDnsRecordIntent[],
902
927
  zones: DomainDoc[],
903
- ): Promise<{
904
- errors: Map<string, IEmailDomainOperationError>;
905
- provenProviderZoneIds: Set<string>;
906
- }> {
928
+ ): Promise<IZoneReconciliationResult> {
907
929
  const errors = new Map<string, IEmailDomainOperationError>();
908
930
  const provenProviderZoneIds = new Set<string>();
931
+ const readyZoneIds = new Set<string>();
909
932
  const byZone = new Map<string, IEmailDnsRecordIntent[]>();
910
933
  for (const intent of intents) {
911
934
  if (!intent.domainId || intent.error) continue;
@@ -921,6 +944,14 @@ export class MailDnsSync {
921
944
  const result = await this.dcRouterRef.dnsManager!.syncDomain(zone.id);
922
945
  if (!result.success) throw new Error(result.message || `Failed to refresh ${zone.name}`);
923
946
  provenProviderZoneIds.add(zone.id);
947
+ readyZoneIds.add(zone.id);
948
+ }
949
+ if (
950
+ zone.source === 'dcrouter'
951
+ && this.dcRouterRef.dnsServer
952
+ && this.dcRouterRef.dnsManager?.dnsServer === this.dcRouterRef.dnsServer
953
+ ) {
954
+ readyZoneIds.add(zone.id);
924
955
  }
925
956
  for (const intent of this.sortIntents(zoneIntents)) {
926
957
  await this.reconcileIntent(intent, zoneIntents);
@@ -939,7 +970,7 @@ export class MailDnsSync {
939
970
  }
940
971
  }
941
972
  }
942
- return { errors, provenProviderZoneIds };
973
+ return { errors, provenProviderZoneIds, readyZoneIds };
943
974
  }
944
975
 
945
976
  private sortIntents(intents: IEmailDnsRecordIntent[]): IEmailDnsRecordIntent[] {
@@ -1104,10 +1135,29 @@ export class MailDnsSync {
1104
1135
  private async validateAndActivate(
1105
1136
  plan: IPlannedDomain,
1106
1137
  errors: IEmailDomainOperationError[],
1107
- zoneErrors: Map<string, IEmailDomainOperationError>,
1138
+ zoneResult: IZoneReconciliationResult,
1139
+ zones: DomainDoc[],
1108
1140
  ): Promise<void> {
1141
+ for (const intent of plan.intents) {
1142
+ if (intent.error || zoneResult.readyZoneIds.has(intent.domainId)) continue;
1143
+ const zone = zones.find((candidate) => candidate.id === intent.domainId);
1144
+ const providerManaged = zone?.source === 'provider';
1145
+ const outcome: IEmailDomainOperationError = {
1146
+ code: providerManaged ? 'DNS_PROVIDER_ZONE_UNPROVEN' : 'DNS_SERVER_NOT_READY',
1147
+ message: providerManaged
1148
+ ? `DNS provider zone ${zone?.name || intent.domainId} was not proven by this reconciliation pass`
1149
+ : `Authoritative DNS runtime is not ready for ${zone?.name || intent.domainId}`,
1150
+ retryable: true,
1151
+ };
1152
+ intent.status = 'pending';
1153
+ intent.verificationOutcome = 'lookup-error';
1154
+ intent.error = { ...outcome, recordKey: intent.key };
1155
+ if (!errors.some((error) => error.code === outcome.code)) errors.push(outcome);
1156
+ }
1109
1157
  const verifiable = plan.intents.filter((intent) => (
1110
- !intent.error && !zoneErrors.has(intent.domainId)
1158
+ !intent.error
1159
+ && !zoneResult.errors.has(intent.domainId)
1160
+ && zoneResult.readyZoneIds.has(intent.domainId)
1111
1161
  ));
1112
1162
  const markResult = (intent: IEmailDnsRecordIntent, result: IMailDnsVerificationResult) => {
1113
1163
  const outcome = result.outcome || (result.valid ? 'valid' : 'propagation');
@@ -1265,13 +1315,13 @@ export class MailDnsSync {
1265
1315
  && plan.revision.mxSlots.length > 0;
1266
1316
  plan.doc.dnsStatus = {
1267
1317
  mx: mailPublicationReady
1268
- ? this.statusForIntent(plan.intents, (intent) => intent.type === 'MX')
1318
+ ? projectMailDnsIntentAggregateStatus(plan.intents, (intent) => intent.type === 'MX')
1269
1319
  : 'unchecked',
1270
1320
  spf: mailPublicationReady
1271
- ? this.statusForIntent(plan.intents, (intent) => intent.type === 'TXT' && intent.value.startsWith('v=spf1'))
1321
+ ? projectMailDnsIntentAggregateStatus(plan.intents, (intent) => intent.type === 'TXT' && intent.value.startsWith('v=spf1'))
1272
1322
  : 'unchecked',
1273
- dkim: this.statusForIntent(plan.intents, (intent) => intent.type === 'TXT' && intent.name.includes('._domainkey.')),
1274
- dmarc: this.statusForIntent(plan.intents, (intent) => intent.type === 'TXT' && intent.value.startsWith('v=DMARC1')),
1323
+ dkim: projectMailDnsIntentAggregateStatus(plan.intents, (intent) => intent.type === 'TXT' && intent.name.includes('._domainkey.')),
1324
+ dmarc: projectMailDnsIntentAggregateStatus(plan.intents, (intent) => intent.type === 'TXT' && intent.value.startsWith('v=DMARC1')),
1275
1325
  lastCheckedAt: now,
1276
1326
  };
1277
1327
  plan.doc.updatedAt = now;
@@ -1307,26 +1357,6 @@ export class MailDnsSync {
1307
1357
  doc.dkim.lastRotatedAt = now;
1308
1358
  }
1309
1359
 
1310
- private statusForIntent(
1311
- intents: IEmailDnsRecordIntent[],
1312
- predicate: (intent: IEmailDnsRecordIntent) => boolean,
1313
- ): 'valid' | 'missing' | 'invalid' | 'unchecked' {
1314
- const selected = intents.filter(predicate);
1315
- if (selected.length === 0) return 'missing';
1316
- if (selected.every((intent) => intent.status === 'valid')) return 'valid';
1317
- if (selected.some((intent) => (
1318
- intent.verificationOutcome === 'duplicate'
1319
- || intent.verificationOutcome === 'conflict'
1320
- || intent.verificationOutcome === 'invalid'
1321
- || intent.status === 'conflict'
1322
- || intent.status === 'failed'
1323
- ))) return 'invalid';
1324
- if (selected.some((intent) => intent.verificationOutcome === 'lookup-error')) {
1325
- return 'unchecked';
1326
- }
1327
- return 'missing';
1328
- }
1329
-
1330
1360
  private async refreshProviderZonesForCleanup(
1331
1361
  docs: EmailDomainDoc[],
1332
1362
  zones: DomainDoc[],
package/ts/email/index.ts CHANGED
@@ -3,6 +3,7 @@ export * from './classes.email-domain.manager.js';
3
3
  export * from './classes.email-route-builder.js';
4
4
  export * from './classes.mail-dns-sync.js';
5
5
  export * from './mail-dns-txt-policy.js';
6
+ export * from './mail-dns-status.js';
6
7
  export * from './reconcile-mail-dns-singleton.js';
7
8
  export * from './classes.mail-egress-coordinator.js';
8
9
  export * from './classes.mail-edge-eligibility.js';
@@ -0,0 +1,35 @@
1
+ import type {
2
+ IEmailDnsRecordIntent,
3
+ TDnsRecordStatus,
4
+ } from '../../ts_interfaces/data/email-domain.js';
5
+
6
+ /** Project one durable reconciliation intent into the operator-facing DNS status. */
7
+ export function projectMailDnsIntentStatus(
8
+ intent: IEmailDnsRecordIntent | undefined,
9
+ ): TDnsRecordStatus {
10
+ if (!intent) return 'unchecked';
11
+ if (
12
+ intent.verificationOutcome === 'duplicate'
13
+ || intent.verificationOutcome === 'conflict'
14
+ || intent.verificationOutcome === 'invalid'
15
+ || intent.status === 'conflict'
16
+ || intent.status === 'failed'
17
+ ) return 'invalid';
18
+ if (intent.verificationOutcome === 'missing') return 'missing';
19
+ if (intent.verificationOutcome === 'valid' && intent.status === 'valid') return 'valid';
20
+ return 'unchecked';
21
+ }
22
+
23
+ /** Aggregate matching intents with deterministic operator-facing precedence. */
24
+ export function projectMailDnsIntentAggregateStatus(
25
+ intents: IEmailDnsRecordIntent[],
26
+ predicate: (intent: IEmailDnsRecordIntent) => boolean,
27
+ ): TDnsRecordStatus {
28
+ const selected = intents.filter(predicate);
29
+ if (selected.length === 0) return 'unchecked';
30
+ const statuses = selected.map(projectMailDnsIntentStatus);
31
+ if (statuses.includes('invalid')) return 'invalid';
32
+ if (statuses.includes('missing')) return 'missing';
33
+ if (statuses.includes('unchecked')) return 'unchecked';
34
+ return 'valid';
35
+ }