@serve.zone/dcrouter 17.0.0 → 17.1.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.
Files changed (141) hide show
  1. package/.smartconfig.json +7 -4
  2. package/deno.json +1 -1
  3. package/dist_serve/bundle.js +1316 -1151
  4. package/dist_ts/00_commitinfo_data.js +1 -1
  5. package/dist_ts/classes.dcrouter.d.ts +3 -1
  6. package/dist_ts/classes.dcrouter.js +113 -52
  7. package/dist_ts/db/classes.cache.cleaner.d.ts +3 -0
  8. package/dist_ts/db/classes.cache.cleaner.js +7 -3
  9. package/dist_ts/db/classes.dcrouter-db.js +1 -2
  10. package/dist_ts/db/documents/classes.cached.email.d.ts +41 -4
  11. package/dist_ts/db/documents/classes.cached.email.js +36 -5
  12. package/dist_ts/db/documents/classes.dns-record.doc.js +3 -1
  13. package/dist_ts/dns/classes.dns-server-runtime.d.ts +0 -1
  14. package/dist_ts/dns/classes.dns-server-runtime.js +4 -36
  15. package/dist_ts/dns/index.d.ts +1 -0
  16. package/dist_ts/dns/index.js +2 -1
  17. package/dist_ts/dns/manager.dns.d.ts +1 -0
  18. package/dist_ts/dns/manager.dns.js +49 -14
  19. package/dist_ts/dns/providers/cloudflare.provider.js +19 -4
  20. package/dist_ts/dns/txt-record-presentation.d.ts +20 -0
  21. package/dist_ts/dns/txt-record-presentation.js +131 -0
  22. package/dist_ts/email/classes.accepted-email-spool.d.ts +32 -3
  23. package/dist_ts/email/classes.accepted-email-spool.js +174 -13
  24. package/dist_ts/email/classes.email-domain.manager.d.ts +14 -1
  25. package/dist_ts/email/classes.email-domain.manager.js +151 -8
  26. package/dist_ts/email/classes.email-settings.manager.js +2 -2
  27. package/dist_ts/email/classes.mail-dns-sync.d.ts +31 -5
  28. package/dist_ts/email/classes.mail-dns-sync.js +508 -117
  29. package/dist_ts/email/classes.smartmta-blob-storage-manager.d.ts +2 -2
  30. package/dist_ts/email/classes.smartmta-blob-storage-manager.js +4 -3
  31. package/dist_ts/email/classes.workapp-mail-manager.d.ts +1 -0
  32. package/dist_ts/email/classes.workapp-mail-manager.js +26 -2
  33. package/dist_ts/email/index.d.ts +2 -0
  34. package/dist_ts/email/index.js +3 -1
  35. package/dist_ts/email/mail-dns-txt-policy.d.ts +18 -0
  36. package/dist_ts/email/mail-dns-txt-policy.js +466 -0
  37. package/dist_ts/email/reconcile-mail-dns-singleton.d.ts +47 -0
  38. package/dist_ts/email/reconcile-mail-dns-singleton.js +159 -0
  39. package/dist_ts/opsserver/classes.opsserver.d.ts +8 -0
  40. package/dist_ts/opsserver/classes.opsserver.js +34 -1
  41. package/dist_ts/opsserver/handlers/certificate.handler.js +25 -4
  42. package/dist_ts/opsserver/handlers/config.handler.js +9 -5
  43. package/dist_ts/opsserver/handlers/dns-provider.handler.js +19 -2
  44. package/dist_ts/opsserver/handlers/dns-record.handler.js +25 -4
  45. package/dist_ts/opsserver/handlers/domain.handler.js +37 -3
  46. package/dist_ts/opsserver/handlers/email-domain.handler.js +5 -7
  47. package/dist_ts/opsserver/handlers/email-ops.handler.d.ts +1 -0
  48. package/dist_ts/opsserver/handlers/email-ops.handler.js +26 -7
  49. package/dist_ts/opsserver/handlers/index.d.ts +1 -0
  50. package/dist_ts/opsserver/handlers/index.js +2 -1
  51. package/dist_ts/opsserver/handlers/logs.handler.d.ts +1 -1
  52. package/dist_ts/opsserver/handlers/logs.handler.js +3 -3
  53. package/dist_ts/opsserver/handlers/realtime.handler.d.ts +37 -0
  54. package/dist_ts/opsserver/handlers/realtime.handler.js +154 -0
  55. package/dist_ts/opsserver/handlers/remoteingress.handler.js +22 -1
  56. package/dist_ts/opsserver/handlers/route-management.handler.js +47 -5
  57. package/dist_ts/opsserver/handlers/stats.handler.js +5 -1
  58. package/dist_ts/paths.d.ts +0 -11
  59. package/dist_ts/paths.js +1 -22
  60. package/dist_ts/plugins.d.ts +0 -34
  61. package/dist_ts/plugins.js +1 -62
  62. package/dist_ts_interfaces/data/email-domain.d.ts +14 -0
  63. package/dist_ts_interfaces/requests/config.d.ts +1 -1
  64. package/dist_ts_interfaces/requests/email-ops.d.ts +34 -1
  65. package/dist_ts_interfaces/requests/index.d.ts +1 -0
  66. package/dist_ts_interfaces/requests/index.js +2 -1
  67. package/dist_ts_interfaces/requests/realtime.d.ts +38 -0
  68. package/dist_ts_interfaces/requests/realtime.js +11 -0
  69. package/dist_ts_migrations/index.d.ts +6 -0
  70. package/dist_ts_migrations/index.js +208 -9
  71. package/dist_ts_web/00_commitinfo_data.js +1 -1
  72. package/dist_ts_web/appstate/certificates.js +9 -2
  73. package/dist_ts_web/appstate/domains.js +16 -5
  74. package/dist_ts_web/appstate/email-domains.d.ts +3 -0
  75. package/dist_ts_web/appstate/email-domains.js +47 -14
  76. package/dist_ts_web/appstate/email-ops.d.ts +1 -0
  77. package/dist_ts_web/appstate/email-ops.js +20 -2
  78. package/dist_ts_web/appstate/realtime.d.ts +27 -0
  79. package/dist_ts_web/appstate/realtime.js +71 -0
  80. package/dist_ts_web/appstate/remoteingress.js +9 -3
  81. package/dist_ts_web/appstate/routes.js +16 -5
  82. package/dist_ts_web/appstate/runtime.js +135 -7
  83. package/dist_ts_web/appstate.d.ts +1 -0
  84. package/dist_ts_web/appstate.js +2 -1
  85. package/dist_ts_web/elements/email/email-domain-creation-progress.d.ts +33 -0
  86. package/dist_ts_web/elements/email/email-domain-creation-progress.js +257 -0
  87. package/dist_ts_web/elements/email/ops-view-email-domains.js +233 -68
  88. package/dist_ts_web/elements/email/ops-view-emails.d.ts +4 -2
  89. package/dist_ts_web/elements/email/ops-view-emails.js +25 -13
  90. package/dist_ts_web/elements/overview/ops-view-config.js +5 -5
  91. package/package.json +22 -19
  92. package/ts/00_commitinfo_data.ts +1 -1
  93. package/ts/classes.dcrouter.ts +124 -57
  94. package/ts/db/classes.cache.cleaner.ts +18 -3
  95. package/ts/db/classes.dcrouter-db.ts +0 -2
  96. package/ts/db/documents/classes.cached.email.ts +78 -4
  97. package/ts/db/documents/classes.dns-record.doc.ts +1 -0
  98. package/ts/dns/classes.dns-server-runtime.ts +9 -45
  99. package/ts/dns/index.ts +1 -0
  100. package/ts/dns/manager.dns.ts +50 -16
  101. package/ts/dns/providers/cloudflare.provider.ts +21 -3
  102. package/ts/dns/txt-record-presentation.ts +134 -0
  103. package/ts/email/classes.accepted-email-spool.ts +220 -14
  104. package/ts/email/classes.email-domain.manager.ts +201 -7
  105. package/ts/email/classes.email-settings.manager.ts +1 -1
  106. package/ts/email/classes.mail-dns-sync.ts +588 -122
  107. package/ts/email/classes.smartmta-blob-storage-manager.ts +3 -2
  108. package/ts/email/classes.workapp-mail-manager.ts +34 -1
  109. package/ts/email/index.ts +2 -0
  110. package/ts/email/mail-dns-txt-policy.ts +505 -0
  111. package/ts/email/reconcile-mail-dns-singleton.ts +224 -0
  112. package/ts/opsserver/classes.opsserver.ts +42 -0
  113. package/ts/opsserver/handlers/certificate.handler.ts +24 -3
  114. package/ts/opsserver/handlers/config.handler.ts +9 -5
  115. package/ts/opsserver/handlers/dns-provider.handler.ts +18 -1
  116. package/ts/opsserver/handlers/dns-record.handler.ts +24 -3
  117. package/ts/opsserver/handlers/domain.handler.ts +36 -2
  118. package/ts/opsserver/handlers/email-domain.handler.ts +4 -5
  119. package/ts/opsserver/handlers/email-ops.handler.ts +29 -6
  120. package/ts/opsserver/handlers/index.ts +1 -0
  121. package/ts/opsserver/handlers/logs.handler.ts +2 -2
  122. package/ts/opsserver/handlers/realtime.handler.ts +222 -0
  123. package/ts/opsserver/handlers/remoteingress.handler.ts +21 -0
  124. package/ts/opsserver/handlers/route-management.handler.ts +46 -4
  125. package/ts/opsserver/handlers/stats.handler.ts +4 -0
  126. package/ts/paths.ts +0 -25
  127. package/ts/plugins.ts +0 -68
  128. package/ts_web/00_commitinfo_data.ts +1 -1
  129. package/ts_web/appstate/certificates.ts +6 -1
  130. package/ts_web/appstate/domains.ts +11 -4
  131. package/ts_web/appstate/email-domains.ts +68 -14
  132. package/ts_web/appstate/email-ops.ts +20 -1
  133. package/ts_web/appstate/realtime.ts +109 -0
  134. package/ts_web/appstate/remoteingress.ts +6 -2
  135. package/ts_web/appstate/routes.ts +11 -4
  136. package/ts_web/appstate/runtime.ts +176 -7
  137. package/ts_web/appstate.ts +1 -0
  138. package/ts_web/elements/email/email-domain-creation-progress.ts +340 -0
  139. package/ts_web/elements/email/ops-view-email-domains.ts +273 -74
  140. package/ts_web/elements/email/ops-view-emails.ts +23 -16
  141. package/ts_web/elements/overview/ops-view-config.ts +4 -4
@@ -11,6 +11,7 @@ import type {
11
11
  IEmailDomainMxSlot,
12
12
  IEmailDomainOperationError,
13
13
  IEmailDkimMaterial,
14
+ TEmailDnsVerificationOutcome,
14
15
  } from '../../ts_interfaces/data/email-domain.js';
15
16
  import type { TDnsRecordType } from '../../ts_interfaces/data/dns-record.js';
16
17
  import {
@@ -18,23 +19,40 @@ import {
18
19
  ReleasedRemoteIngressEgressIdentitySource,
19
20
  type IEligibleMailEdge,
20
21
  } from './classes.mail-edge-eligibility.js';
22
+ import {
23
+ evaluateMailTxtIntent,
24
+ type IMailDnsTxtPolicyResult,
25
+ } from './mail-dns-txt-policy.js';
26
+ import {
27
+ applyMailDnsVerificationResult,
28
+ reconcileMailDnsSingleton,
29
+ } from './reconcile-mail-dns-singleton.js';
21
30
 
22
31
  export const MAIL_DNS_SYNC_CREATED_BY = 'mail-dns-reconciler';
23
32
  export const MAIL_DNS_MANAGED_BY = 'mail-dns-reconciler';
24
33
  export const MAIL_DNS_RETRY_INTERVAL_MS = 5 * 60_000;
25
-
34
+ export const MAIL_DNS_MAX_RETRY_INTERVAL_MS = 60 * 60_000;
26
35
 
27
36
  export interface IMailDnsVerificationResult {
28
37
  valid: boolean;
38
+ outcome?: TEmailDnsVerificationOutcome;
29
39
  reason?: string;
40
+ effectiveValue?: string;
30
41
  }
31
42
 
32
43
  export interface IMailDnsVerifier {
33
44
  verifyRecord(intent: IEmailDnsRecordIntent): Promise<IMailDnsVerificationResult>;
34
- verifyMxSet?(intents: IEmailDnsRecordIntent[]): Promise<IMailDnsVerificationResult>;
45
+ verifyMxSet?(
46
+ intents: IEmailDnsRecordIntent[],
47
+ previousValues?: string[],
48
+ ): Promise<IMailDnsVerificationResult>;
35
49
  verifyForwardConfirmedIdentity(
36
50
  identity: IEmailDomainEdgeIdentity,
37
51
  ): Promise<IMailDnsVerificationResult>;
52
+ evaluateTxtRecords?(
53
+ intent: IEmailDnsRecordIntent,
54
+ values: string[],
55
+ ): Promise<IMailDnsTxtPolicyResult>;
38
56
  destroy?(): void;
39
57
  }
40
58
 
@@ -47,6 +65,53 @@ function normalizeMx(value: string): string {
47
65
  return `${Number(priority)} ${normalizeHostname(exchange)}`;
48
66
  }
49
67
 
68
+ function parseMx(value: string): string | undefined {
69
+ const match = value.trim().match(/^(\d{1,5})\s+([^\s]+)$/);
70
+ if (!match) return undefined;
71
+ const priority = Number.parseInt(match[1], 10);
72
+ const exchange = normalizeHostname(match[2]);
73
+ if (
74
+ priority < 0
75
+ || priority > 65_535
76
+ || !exchange
77
+ || exchange.length > 253
78
+ || !exchange.split('.').every((label) => (
79
+ label.length > 0
80
+ && label.length <= 63
81
+ && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(label)
82
+ ))
83
+ ) return undefined;
84
+ return `${priority} ${exchange}`;
85
+ }
86
+
87
+ export function evaluateMailMxValues(
88
+ intents: IEmailDnsRecordIntent[],
89
+ rawValues: string[],
90
+ previousValues: string[] = [],
91
+ ): IMailDnsVerificationResult {
92
+ const parsed = rawValues.map(parseMx);
93
+ if (parsed.some((value) => value === undefined)) {
94
+ return { valid: false, outcome: 'invalid', reason: 'MX RRset contains an invalid priority or exchange' };
95
+ }
96
+ const actual = parsed as string[];
97
+ const desired = new Set(intents.map((intent) => normalizeMx(intent.value)));
98
+ const previous = new Set(previousValues.map(normalizeMx));
99
+ const counts = new Map<string, number>();
100
+ for (const value of actual) counts.set(value, (counts.get(value) || 0) + 1);
101
+ const unexpected = actual.filter((value) => !desired.has(value) && !previous.has(value));
102
+ if (unexpected.length > 0) {
103
+ return { valid: false, outcome: 'conflict', reason: `MX RRset contains unexpected value ${unexpected[0]}` };
104
+ }
105
+ if ([...counts.values()].some((count) => count > 1)) {
106
+ return { valid: false, outcome: 'duplicate', reason: 'MX RRset contains duplicate values' };
107
+ }
108
+ const missing = [...desired].filter((value) => !counts.has(value));
109
+ if (missing.length > 0) {
110
+ return { valid: false, outcome: 'propagation', reason: `MX ${missing[0]} has not propagated` };
111
+ }
112
+ return { valid: true, outcome: 'valid' };
113
+ }
114
+
50
115
  function expandIpv6(address: string): string[] | undefined {
51
116
  let normalized = address.toLowerCase();
52
117
  if (normalized.includes('.')) {
@@ -89,49 +154,107 @@ export class SmartDnsMailVerifier implements IMailDnsVerifier {
89
154
  return this.client;
90
155
  }
91
156
 
157
+ private async queryValues(
158
+ name: string,
159
+ type: 'A' | 'AAAA' | 'MX' | 'TXT' | 'PTR',
160
+ ): Promise<
161
+ | { status: 'success'; values: string[] }
162
+ | { status: 'missing'; reason: string }
163
+ | { status: 'error'; reason: string }
164
+ > {
165
+ const result = await this.getClient().queryRecords(name, type);
166
+ if (result.status === 'success') {
167
+ return {
168
+ status: 'success',
169
+ values: result.records.map((record) => String(record.value).trim()),
170
+ };
171
+ }
172
+ if (result.status === 'missing') {
173
+ return { status: 'missing', reason: result.missingReason };
174
+ }
175
+ return {
176
+ status: 'error',
177
+ reason: `${result.error.code}: ${result.error.message}`,
178
+ };
179
+ }
180
+
92
181
  public async verifyRecord(intent: IEmailDnsRecordIntent): Promise<IMailDnsVerificationResult> {
93
182
  try {
94
- const records = await this.getClient().getRecords(intent.name, intent.type);
95
- const values = (records || []).map((record) => String(record.value).trim());
183
+ const query = await this.queryValues(intent.name, intent.type);
184
+ if (query.status === 'error') {
185
+ return { valid: false, outcome: 'lookup-error', reason: `DNS lookup failed: ${query.reason}` };
186
+ }
187
+ if (query.status === 'missing') {
188
+ return { valid: false, outcome: 'missing', reason: `${intent.type} ${intent.name} is missing` };
189
+ }
190
+ const values = query.values;
96
191
  if (intent.type === 'MX') {
97
- return values.some((value) => normalizeMx(value) === normalizeMx(intent.value))
98
- ? { valid: true }
99
- : { valid: false, reason: `MX ${intent.value} has not propagated` };
192
+ return evaluateMailMxValues([intent], values);
100
193
  }
101
194
  if (intent.type === 'TXT') {
102
- const exact = values.filter((value) => value === intent.value.trim());
103
- const relevant = intent.value.startsWith('v=spf1')
104
- ? values.filter((value) => value.toLowerCase().startsWith('v=spf1'))
105
- : intent.value.startsWith('v=DMARC1')
106
- ? values.filter((value) => value.toLowerCase().startsWith('v=dmarc1'))
107
- : values;
108
- return exact.length === 1 && relevant.length === 1
109
- ? { valid: true }
110
- : { valid: false, reason: `TXT ${intent.name} is missing, conflicting, or duplicated` };
195
+ return await this.evaluateTxtRecords(intent, values).then((result) => ({
196
+ valid: result.outcome === 'valid',
197
+ ...result,
198
+ }));
111
199
  }
112
- const exact = values.filter((value) => normalizeHostname(value) === normalizeHostname(intent.value));
113
- return exact.length === 1 && values.length === 1
114
- ? { valid: true }
115
- : { valid: false, reason: `${intent.type} ${intent.name} is missing, conflicting, or duplicated` };
200
+ const expectedFamily = intent.type === 'A' ? 4 : 6;
201
+ if (values.some((value) => plugins.smartnetwork.getIpVersion(value) !== expectedFamily)) {
202
+ return {
203
+ valid: false,
204
+ outcome: 'invalid',
205
+ reason: `${intent.type} ${intent.name} contains an invalid address`,
206
+ };
207
+ }
208
+ const normalizedValues = values.map((value) => normalizeHostname(value));
209
+ const expected = normalizeHostname(intent.value);
210
+ const exact = normalizedValues.filter((value) => value === expected);
211
+ const unexpected = normalizedValues.filter((value) => value !== expected);
212
+ const outcome: TEmailDnsVerificationOutcome = unexpected.length > 0
213
+ ? 'conflict'
214
+ : exact.length > 1
215
+ ? 'duplicate'
216
+ : exact.length === 0
217
+ ? 'propagation'
218
+ : 'valid';
219
+ return {
220
+ valid: outcome === 'valid',
221
+ outcome,
222
+ ...(outcome !== 'valid' ? { reason: `${intent.type} ${intent.name} did not match exactly` } : {}),
223
+ };
116
224
  } catch (error: unknown) {
117
- return { valid: false, reason: `DNS lookup failed: ${(error as Error).message}` };
225
+ return { valid: false, outcome: 'lookup-error', reason: `DNS lookup failed: ${(error as Error).message}` };
118
226
  }
119
227
  }
120
228
 
121
- public async verifyMxSet(intents: IEmailDnsRecordIntent[]): Promise<IMailDnsVerificationResult> {
229
+ public async evaluateTxtRecords(
230
+ intent: IEmailDnsRecordIntent,
231
+ values: string[],
232
+ ): Promise<IMailDnsTxtPolicyResult> {
233
+ return await evaluateMailTxtIntent(intent, values, {
234
+ resolveDns: async (name, type) => {
235
+ const query = await this.queryValues(name, type);
236
+ if (query.status === 'error') throw new Error(query.reason);
237
+ return query.status === 'missing' ? [] : query.values;
238
+ },
239
+ });
240
+ }
241
+
242
+ public async verifyMxSet(
243
+ intents: IEmailDnsRecordIntent[],
244
+ previousValues: string[] = [],
245
+ ): Promise<IMailDnsVerificationResult> {
122
246
  if (intents.length === 0) return { valid: false, reason: 'No desired MX records' };
123
247
  try {
124
- const records = await this.getClient().getRecords(intents[0].name, 'MX');
125
- const actual = (records || []).map((record) => normalizeMx(String(record.value))).sort();
126
- const desired = intents.map((intent) => normalizeMx(intent.value)).sort();
127
- return actual.length === desired.length && actual.every((value, index) => value === desired[index])
128
- ? { valid: true }
129
- : {
130
- valid: false,
131
- reason: `MX set for ${intents[0].name} does not exactly match priorities and exchanges`,
132
- };
248
+ const query = await this.queryValues(intents[0].name, 'MX');
249
+ if (query.status === 'error') {
250
+ return { valid: false, outcome: 'lookup-error', reason: `MX lookup failed: ${query.reason}` };
251
+ }
252
+ if (query.status === 'missing') {
253
+ return { valid: false, outcome: 'missing', reason: `MX ${intents[0].name} is missing` };
254
+ }
255
+ return evaluateMailMxValues(intents, query.values, previousValues);
133
256
  } catch (error: unknown) {
134
- return { valid: false, reason: `MX lookup failed: ${(error as Error).message}` };
257
+ return { valid: false, outcome: 'lookup-error', reason: `MX lookup failed: ${(error as Error).message}` };
135
258
  }
136
259
  }
137
260
 
@@ -143,16 +266,24 @@ export class SmartDnsMailVerifier implements IMailDnsVerifier {
143
266
  if (identity.ipv6) checks.push({ family: 'AAAA', address: identity.ipv6 });
144
267
  try {
145
268
  for (const check of checks) {
146
- const ptrRecords = await this.getClient().getRecords(toReverseDnsName(check.address), 'PTR');
147
- const ptrNames = (ptrRecords || []).map((record) => normalizeHostname(String(record.value)));
269
+ const ptrQuery = await this.queryValues(toReverseDnsName(check.address), 'PTR');
270
+ if (ptrQuery.status === 'error') {
271
+ return { valid: false, outcome: 'lookup-error', reason: `PTR lookup failed: ${ptrQuery.reason}` };
272
+ }
273
+ const ptrNames = ptrQuery.status === 'missing'
274
+ ? []
275
+ : ptrQuery.values.map(normalizeHostname);
148
276
  if (ptrNames.length !== 1 || ptrNames[0] !== normalizeHostname(identity.hostname)) {
149
277
  return {
150
278
  valid: false,
151
279
  reason: `PTR for ${check.address} must be exactly ${identity.hostname}`,
152
280
  };
153
281
  }
154
- const forwardRecords = await this.getClient().getRecords(identity.hostname, check.family);
155
- const forwardIps = (forwardRecords || []).map((record) => String(record.value));
282
+ const forwardQuery = await this.queryValues(identity.hostname, check.family);
283
+ if (forwardQuery.status === 'error') {
284
+ return { valid: false, outcome: 'lookup-error', reason: `${check.family} lookup failed: ${forwardQuery.reason}` };
285
+ }
286
+ const forwardIps = forwardQuery.status === 'missing' ? [] : forwardQuery.values;
156
287
  if (!forwardIps.includes(check.address)) {
157
288
  return {
158
289
  valid: false,
@@ -192,7 +323,11 @@ export class MailDnsSync {
192
323
  private stopped = false;
193
324
  private dirty = false;
194
325
  private runPromise?: Promise<void>;
195
- private retryTimer?: ReturnType<typeof setInterval>;
326
+ private retryTimer?: ReturnType<typeof setTimeout>;
327
+ private requestedGeneration = 0;
328
+ private completedGeneration = 0;
329
+ private forceAll = false;
330
+ private forcedDomainIds = new Set<string>();
196
331
  private edgeEligibility: MailEdgeEligibility;
197
332
 
198
333
  constructor(
@@ -212,12 +347,11 @@ export class MailDnsSync {
212
347
  }
213
348
  this.started = true;
214
349
  this.stopped = false;
215
- this.retryTimer = setInterval(() => this.requestSync('periodic retry'), MAIL_DNS_RETRY_INTERVAL_MS);
216
- this.dirty = true;
350
+ this.queueRequest(undefined, true);
217
351
  try {
218
352
  await this.kick('startup');
219
353
  } catch (error: unknown) {
220
- if (this.retryTimer) clearInterval(this.retryTimer);
354
+ if (this.retryTimer) clearTimeout(this.retryTimer);
221
355
  this.retryTimer = undefined;
222
356
  this.started = false;
223
357
  this.dirty = false;
@@ -230,16 +364,16 @@ export class MailDnsSync {
230
364
  this.started = false;
231
365
  this.dirty = false;
232
366
  if (this.retryTimer) {
233
- clearInterval(this.retryTimer);
367
+ clearTimeout(this.retryTimer);
234
368
  this.retryTimer = undefined;
235
369
  }
236
370
  await this.runPromise?.catch(() => undefined);
237
371
  this.verifier.destroy?.();
238
372
  }
239
373
 
240
- public requestSync(reason: string): void {
374
+ public requestSync(reason: string, domainIds?: string[]): void {
241
375
  if (this.stopped) return;
242
- this.dirty = true;
376
+ this.queueRequest(domainIds, domainIds === undefined);
243
377
  if (!this.started) return;
244
378
  this.kick(reason).catch((error: unknown) => {
245
379
  logger.log('error', `MailDnsSync failed (${reason}): ${(error as Error).message}`);
@@ -247,36 +381,79 @@ export class MailDnsSync {
247
381
  }
248
382
 
249
383
  /** Coalescing entry point used by API actions that need the durable outcome. */
250
- public async sync(reason: string): Promise<void> {
384
+ public async sync(reason: string, domainIds?: string[]): Promise<void> {
385
+ if (this.stopped) throw new Error('MailDnsSync is stopped');
386
+ const ticket = this.queueRequest(domainIds, domainIds === undefined);
387
+ while (this.completedGeneration < ticket) {
388
+ await this.kick(reason);
389
+ }
390
+ }
391
+
392
+ public async syncDomain(domainId: string, reason: string): Promise<{
393
+ requestedGeneration: number;
394
+ completedGeneration: number;
395
+ domain: EmailDomainDoc | null;
396
+ records: DnsRecordDoc[];
397
+ }> {
251
398
  if (this.stopped) throw new Error('MailDnsSync is stopped');
399
+ const requestedGeneration = this.queueRequest([domainId], false);
400
+ while (this.completedGeneration < requestedGeneration) {
401
+ await this.kick(reason);
402
+ }
403
+ const domain = await EmailDomainDoc.findById(domainId);
404
+ return {
405
+ requestedGeneration,
406
+ completedGeneration: this.completedGeneration,
407
+ domain,
408
+ records: domain ? await DnsRecordDoc.findByDomainId(domain.linkedDomainId) : [],
409
+ };
410
+ }
411
+
412
+ private queueRequest(domainIds: string[] | undefined, forceAll: boolean): number {
413
+ this.requestedGeneration++;
252
414
  this.dirty = true;
253
- await this.kick(reason);
415
+ if (forceAll) this.forceAll = true;
416
+ for (const domainId of domainIds || []) this.forcedDomainIds.add(domainId);
417
+ return this.requestedGeneration;
254
418
  }
255
419
 
256
420
  private async kick(reason: string): Promise<void> {
257
421
  if (this.runPromise) return await this.runPromise;
258
422
  // Defer execution one microtask so runPromise is assigned before the first
259
423
  // awaited provider/eligibility hook can synchronously enqueue another event.
260
- this.runPromise = Promise.resolve().then(() => this.runLoop(reason, false)).finally(() => {
424
+ this.runPromise = Promise.resolve().then(() => this.runLoop(reason)).finally(() => {
261
425
  this.runPromise = undefined;
262
426
  if (this.dirty && this.started && !this.stopped) {
263
- this.requestSync(`${reason} (follow-up)`);
427
+ this.kick(`${reason} (follow-up)`).catch((error: unknown) => {
428
+ logger.log('error', `MailDnsSync follow-up failed: ${(error as Error).message}`);
429
+ });
264
430
  }
431
+ this.scheduleNextDue().catch((error: unknown) => {
432
+ logger.log('error', `MailDnsSync scheduling failed: ${(error as Error).message}`);
433
+ });
265
434
  });
266
435
  return await this.runPromise;
267
436
  }
268
437
 
269
- private async runLoop(reason: string, singlePass: boolean): Promise<void> {
438
+ private async runLoop(reason: string): Promise<void> {
270
439
  do {
440
+ const generation = this.requestedGeneration;
441
+ const forceAll = this.forceAll;
442
+ const forcedDomainIds = new Set(this.forcedDomainIds);
271
443
  this.dirty = false;
272
- await this.reconcile(reason);
273
- if (singlePass) break;
444
+ this.forceAll = false;
445
+ this.forcedDomainIds.clear();
446
+ await this.reconcile(reason, { forceAll, forcedDomainIds });
447
+ this.completedGeneration = Math.max(this.completedGeneration, generation);
274
448
  // An event during the run leaves dirty=true and forces exactly one
275
449
  // immediate follow-up; repeated events coalesce into that pass.
276
450
  } while (this.dirty && !this.stopped);
277
451
  }
278
452
 
279
- private async reconcile(reason: string): Promise<void> {
453
+ private async reconcile(
454
+ reason: string,
455
+ work: { forceAll: boolean; forcedDomainIds: Set<string> },
456
+ ): Promise<void> {
280
457
  if (this.dcRouterRef.options.dbConfig?.enabled === false) return;
281
458
  const dnsManager = this.dcRouterRef.dnsManager;
282
459
  if (!dnsManager) {
@@ -284,21 +461,30 @@ export class MailDnsSync {
284
461
  return;
285
462
  }
286
463
 
464
+ const zones = await DomainDoc.findAll();
465
+ const allDocs = await EmailDomainDoc.findAll();
466
+ const now = Date.now();
467
+ const docs = allDocs.filter((doc) => (
468
+ work.forceAll
469
+ || work.forcedDomainIds.has(doc.id)
470
+ || this.isDomainDue(doc, now)
471
+ ));
472
+ if (docs.length === 0) return;
473
+
287
474
  // Refresh the broad operational status independently from per-domain pins.
288
475
  await this.edgeEligibility.resolveForDomain();
289
-
290
- const zones = await DomainDoc.findAll();
291
- const docs = await EmailDomainDoc.findAll();
292
476
  const plans: IPlannedDomain[] = [];
477
+ const deletingDocs: EmailDomainDoc[] = [];
293
478
  for (const loadedDoc of docs) {
294
479
  const doc = await this.dcRouterRef.emailDomainManager?.stageDkimRotationIfDue?.(loadedDoc.id)
295
480
  || loadedDoc;
296
481
  if (doc.reconciliation?.lifecycleStatus === 'deleting') {
297
482
  await this.persistDeletingAttempt(doc);
483
+ deletingDocs.push(doc);
298
484
  continue;
299
485
  }
300
486
  const plan = await this.buildPlan(doc, zones);
301
- await this.persistDesiredPlan(plan);
487
+ await this.persistDesiredPlan(plan, zones);
302
488
  plans.push(plan);
303
489
  }
304
490
 
@@ -309,7 +495,7 @@ export class MailDnsSync {
309
495
  }
310
496
  }
311
497
 
312
- const zoneErrors = await this.reconcileZones([...uniqueIntents.values()], zones);
498
+ const zoneResult = await this.reconcileZones([...uniqueIntents.values()], zones);
313
499
  for (const plan of plans) {
314
500
  const errors = [...plan.preliminaryErrors];
315
501
  for (const intent of plan.intents) {
@@ -317,20 +503,90 @@ export class MailDnsSync {
317
503
  if (outcome) Object.assign(intent, outcome);
318
504
  if (intent.error) errors.push(intent.error);
319
505
  }
320
- await this.validateAndActivate(plan, errors, zoneErrors);
506
+ await this.validateAndActivate(plan, errors, zoneResult.errors);
321
507
  }
322
508
 
323
- await this.cleanupStaleManagedRecords(zones);
324
- await this.finalizeDeletingDomains();
509
+ const cleanupProof = await this.refreshProviderZonesForCleanup(
510
+ [...plans.map((plan) => plan.doc), ...deletingDocs],
511
+ zones,
512
+ zoneResult.provenProviderZoneIds,
513
+ );
514
+ await this.cleanupStaleManagedRecords(
515
+ zones,
516
+ cleanupProof,
517
+ new Set(plans.map((plan) => `domain:${plan.doc.id}`).concat(
518
+ deletingDocs.map((doc) => `domain:${doc.id}`),
519
+ )),
520
+ work.forceAll,
521
+ );
522
+ await this.finalizeDeletingDomains(deletingDocs, zones);
325
523
  await this.dcRouterRef.emailDomainManager?.syncManagedDomainsToRuntime();
326
524
  await this.dcRouterRef.workAppMailManager?.applyStoredIdentitiesToRuntime();
525
+ this.dcRouterRef.opsServer?.invalidateRealtime?.(['emailDomains', 'dns'], {
526
+ changedIds: docs.map((doc) => doc.id),
527
+ reason: `mail-dns-reconciled:${reason}`,
528
+ });
529
+ }
530
+
531
+ private isDomainDue(doc: EmailDomainDoc, now: number): boolean {
532
+ const retryAt = Date.parse(doc.reconciliation?.retryAt || '');
533
+ if (Number.isFinite(retryAt) && retryAt <= now) return true;
534
+ if (!doc.reconciliation?.lastAttemptAt) return true;
535
+ const rotationDueAt = this.getDkimRotationDueAt(doc, now);
536
+ return rotationDueAt !== undefined && rotationDueAt <= now;
537
+ }
538
+
539
+ private getDkimRotationDueAt(doc: EmailDomainDoc, now: number): number | undefined {
540
+ if (!doc.dkim.rotateKeys || !doc.dkim.rotationIntervalDays) return undefined;
541
+ const activeDkim = this.getActiveDkim(doc);
542
+ const lastRotation = Date.parse(
543
+ activeDkim?.promotedAt
544
+ || doc.dkim.lastRotatedAt
545
+ || activeDkim?.createdAt
546
+ || doc.createdAt,
547
+ );
548
+ if (!Number.isFinite(lastRotation)) return undefined;
549
+ const dueAt = lastRotation + doc.dkim.rotationIntervalDays * 86_400_000;
550
+ const canStage = !doc.pendingDkim
551
+ && this.dcRouterRef.emailDomainManager?.supportsSelectorCorrectSigning?.() === true
552
+ && typeof this.dcRouterRef.emailDomainManager?.stageDkimRotationIfDue === 'function';
553
+ if (canStage) return dueAt;
554
+ const lastAttemptAt = Date.parse(doc.reconciliation?.lastAttemptAt || '');
555
+ const retryReference = Number.isFinite(lastAttemptAt) ? lastAttemptAt : now;
556
+ return Math.max(dueAt, retryReference + MAIL_DNS_RETRY_INTERVAL_MS);
557
+ }
558
+
559
+ private async scheduleNextDue(): Promise<void> {
560
+ if (this.retryTimer) clearTimeout(this.retryTimer);
561
+ this.retryTimer = undefined;
562
+ if (!this.started || this.stopped) return;
563
+ const dueTimes: number[] = [];
564
+ const now = Date.now();
565
+ for (const doc of await EmailDomainDoc.findAll()) {
566
+ const retryAt = Date.parse(doc.reconciliation?.retryAt || '');
567
+ if (Number.isFinite(retryAt)) dueTimes.push(retryAt);
568
+ const rotationDueAt = this.getDkimRotationDueAt(doc, now);
569
+ if (rotationDueAt !== undefined) dueTimes.push(rotationDueAt);
570
+ }
571
+ if (dueTimes.length === 0) return;
572
+ const delay = Math.max(1, Math.min(2_147_483_647, Math.min(...dueTimes) - now));
573
+ this.retryTimer = setTimeout(() => {
574
+ if (this.stopped) return;
575
+ this.queueRequest([], false);
576
+ this.kick('scheduled retry').catch((error: unknown) => {
577
+ logger.log('error', `MailDnsSync scheduled retry failed: ${(error as Error).message}`);
578
+ });
579
+ }, delay);
327
580
  }
328
581
 
329
582
  private async buildPlan(doc: EmailDomainDoc, zones: DomainDoc[]): Promise<IPlannedDomain> {
330
583
  const now = new Date().toISOString();
331
584
  const eligibility = await this.edgeEligibility.resolveForDomain(doc);
332
585
  const preliminaryErrors: IEmailDomainOperationError[] = [
333
- ...(doc.reconciliation?.errors || []).filter((error) => error.code === 'DKIM_ROTATION_STAGE_FAILED'),
586
+ ...(doc.reconciliation?.errors || []).filter((error) => (
587
+ error.code === 'DKIM_ROTATION_STAGE_FAILED'
588
+ || error.code === 'DKIM_KEY_REPAIR_FAILED'
589
+ )),
334
590
  ];
335
591
  if (!eligibility.supported) {
336
592
  preliminaryErrors.push({
@@ -566,14 +822,30 @@ export class MailDnsSync {
566
822
  return `v=spf1 ${[...terms].sort().join(' ')} -all`.replace(/\s+/g, ' ').trim();
567
823
  }
568
824
 
569
- private async persistDesiredPlan(plan: IPlannedDomain): Promise<void> {
825
+ private async persistDesiredPlan(plan: IPlannedDomain, zones: DomainDoc[]): Promise<void> {
570
826
  const previous = plan.doc.reconciliation;
571
827
  const previousSignature = JSON.stringify((previous?.intents || []).map((intent) => [intent.key, intent.value]));
572
828
  const nextSignature = JSON.stringify(plan.intents.map((intent) => [intent.key, intent.value]));
573
829
  const generation = (previous?.desiredGeneration || 0) + (previousSignature === nextSignature ? 0 : 1);
830
+ const desiredGeneration = Math.max(generation, 1);
831
+ plan.revision.generation = desiredGeneration;
832
+ const providerZoneIds = new Set(previous?.providerZoneIds || []);
833
+ const providerZones = new Set(zones.filter((zone) => zone.source === 'provider').map((zone) => zone.id));
834
+ for (const intent of plan.intents) {
835
+ if (providerZones.has(intent.domainId)) providerZoneIds.add(intent.domainId);
836
+ }
837
+ const linkedZone = zones.find((zone) => zone.id === plan.doc.linkedDomainId);
838
+ if (linkedZone?.source === 'provider') providerZoneIds.add(linkedZone.id);
839
+ for (const record of await DnsRecordDoc.findAll()) {
840
+ if (
841
+ record.managedBy === MAIL_DNS_MANAGED_BY
842
+ && record.managedOwnerId === `domain:${plan.doc.id}`
843
+ && providerZones.has(record.domainId)
844
+ ) providerZoneIds.add(record.domainId);
845
+ }
574
846
  plan.doc.reconciliation = {
575
847
  lifecycleStatus: previous?.lifecycleStatus === 'active' ? 'active' : 'pending',
576
- desiredGeneration: Math.max(generation, 1),
848
+ desiredGeneration,
577
849
  activeRevision: previous?.activeRevision,
578
850
  pendingRevision: plan.revision,
579
851
  intents: plan.intents,
@@ -582,6 +854,8 @@ export class MailDnsSync {
582
854
  egressIdentityProof: plan.egressIdentitySupported ? 'supported' : 'unsupported',
583
855
  selectorCorrectSigning: plan.selectorCorrectSigningSupported ? 'supported' : 'unsupported',
584
856
  },
857
+ providerZoneIds: [...providerZoneIds].sort(),
858
+ retryAttempt: previous?.retryAttempt,
585
859
  lastAttemptAt: new Date().toISOString(),
586
860
  retryAt: new Date(Date.now() + MAIL_DNS_RETRY_INTERVAL_MS).toISOString(),
587
861
  };
@@ -609,8 +883,12 @@ export class MailDnsSync {
609
883
  private async reconcileZones(
610
884
  intents: IEmailDnsRecordIntent[],
611
885
  zones: DomainDoc[],
612
- ): Promise<Map<string, IEmailDomainOperationError>> {
886
+ ): Promise<{
887
+ errors: Map<string, IEmailDomainOperationError>;
888
+ provenProviderZoneIds: Set<string>;
889
+ }> {
613
890
  const errors = new Map<string, IEmailDomainOperationError>();
891
+ const provenProviderZoneIds = new Set<string>();
614
892
  const byZone = new Map<string, IEmailDnsRecordIntent[]>();
615
893
  for (const intent of intents) {
616
894
  if (!intent.domainId || intent.error) continue;
@@ -625,6 +903,7 @@ export class MailDnsSync {
625
903
  if (zone.source === 'provider') {
626
904
  const result = await this.dcRouterRef.dnsManager!.syncDomain(zone.id);
627
905
  if (!result.success) throw new Error(result.message || `Failed to refresh ${zone.name}`);
906
+ provenProviderZoneIds.add(zone.id);
628
907
  }
629
908
  for (const intent of this.sortIntents(zoneIntents)) {
630
909
  await this.reconcileIntent(intent, zoneIntents);
@@ -637,12 +916,13 @@ export class MailDnsSync {
637
916
  };
638
917
  errors.set(zone.id, outcome);
639
918
  for (const intent of zoneIntents) {
640
- intent.status = 'failed';
919
+ intent.status = 'pending';
920
+ intent.verificationOutcome = 'lookup-error';
641
921
  intent.error = { ...outcome, recordKey: intent.key };
642
922
  }
643
923
  }
644
924
  }
645
- return errors;
925
+ return { errors, provenProviderZoneIds };
646
926
  }
647
927
 
648
928
  private sortIntents(intents: IEmailDnsRecordIntent[]): IEmailDnsRecordIntent[] {
@@ -663,7 +943,38 @@ export class MailDnsSync {
663
943
  const rrset = records.filter((record) => (
664
944
  record.name.toLowerCase() === intent.name.toLowerCase() && record.type === intent.type
665
945
  ));
946
+ if (await reconcileMailDnsSingleton({
947
+ intent,
948
+ rrset,
949
+ dnsManager: this.dcRouterRef.dnsManager!,
950
+ managedBy: MAIL_DNS_MANAGED_BY,
951
+ createdBy: MAIL_DNS_SYNC_CREATED_BY,
952
+ evaluate: async (candidate, values) => await this.evaluateTxtRecords(candidate, values),
953
+ })) return;
954
+
955
+ const exactOwner = rrset
956
+ .filter((record) => (
957
+ record.managedBy === MAIL_DNS_MANAGED_BY
958
+ && record.managedOwnerId === intent.ownerId
959
+ && record.managedRecordKey === intent.key
960
+ ))
961
+ .sort((left, right) => left.id.localeCompare(right.id));
666
962
  const exact = rrset.filter((record) => this.valuesEqual(intent.type, record.value, intent.value));
963
+ const exactOtherManaged = exact.filter((record) => (
964
+ record.managedBy === MAIL_DNS_MANAGED_BY
965
+ && (record.managedOwnerId !== intent.ownerId || record.managedRecordKey !== intent.key)
966
+ ));
967
+ if (exactOtherManaged.length > 0) {
968
+ intent.status = 'conflict';
969
+ intent.verificationOutcome = 'conflict';
970
+ intent.error = {
971
+ code: 'DNS_MANAGED_OWNER_CONFLICT',
972
+ message: `Another managed owner controls ${intent.type} ${intent.name}`,
973
+ recordKey: intent.key,
974
+ retryable: false,
975
+ };
976
+ return;
977
+ }
667
978
  const desiredValues = zoneIntents
668
979
  .filter((candidate) => candidate.name.toLowerCase() === intent.name.toLowerCase() && candidate.type === intent.type)
669
980
  .map((candidate) => candidate.value);
@@ -674,8 +985,9 @@ export class MailDnsSync {
674
985
  const conflictingUnmanaged = unmanaged.filter((record) => (
675
986
  !desiredValues.some((desired) => this.valuesEqual(intent.type, record.value, desired))
676
987
  ));
677
- if (conflictingUnmanaged.length > 0 || (intent.type === 'TXT' && exactUnmanaged.length > 1)) {
988
+ if (conflictingUnmanaged.length > 0) {
678
989
  intent.status = 'conflict';
990
+ intent.verificationOutcome = 'conflict';
679
991
  intent.error = {
680
992
  code: 'DNS_UNMANAGED_CONFLICT',
681
993
  message: `Unmanaged ${intent.type} record conflicts with ${intent.name}`,
@@ -684,35 +996,32 @@ export class MailDnsSync {
684
996
  };
685
997
  return;
686
998
  }
687
- if (exactUnmanaged.length === 1) {
999
+ if (exactUnmanaged.length > 0) {
688
1000
  // Exact operator record satisfies the intent without adoption/deletion.
689
- // Remove only a dcrouter-owned duplicate of this same intent; other MX
1001
+ // Remove only dcrouter-owned duplicates of this same intent; other MX
690
1002
  // values in the desired RRset remain independently managed.
691
- const ownedDuplicates = rrset.filter((record) => (
692
- record.managedBy === MAIL_DNS_MANAGED_BY
693
- && (
694
- record.managedRecordKey === intent.key
695
- || this.valuesEqual(intent.type, record.value, intent.value)
696
- )
697
- ));
698
- for (const duplicate of ownedDuplicates) {
1003
+ for (const duplicate of exactOwner) {
699
1004
  const result = await this.dcRouterRef.dnsManager!.deleteRecord(duplicate.id);
700
1005
  if (!result.success) throw new Error(result.message || `Failed to delete duplicate ${intent.key}`);
701
1006
  }
1007
+ if (exactUnmanaged.length > 1) {
1008
+ intent.status = 'conflict';
1009
+ intent.verificationOutcome = 'duplicate';
1010
+ intent.error = {
1011
+ code: 'DNS_PUBLIC_RRSET_DUPLICATE',
1012
+ message: `Operator ${intent.type} RRset has duplicate values at ${intent.name}`,
1013
+ recordKey: intent.key,
1014
+ retryable: false,
1015
+ };
1016
+ return;
1017
+ }
702
1018
  intent.status = 'satisfied';
1019
+ intent.satisfactionSource = 'operator';
703
1020
  return;
704
1021
  }
705
1022
 
706
- let managed = rrset.find((record) => record.managedRecordKey === intent.key);
707
- if (!managed) {
708
- const adoptable = exact.find((record) => record.managedBy === MAIL_DNS_MANAGED_BY);
709
- if (adoptable) {
710
- adoptable.managedOwnerId = intent.ownerId;
711
- adoptable.managedRecordKey = intent.key;
712
- await adoptable.save();
713
- managed = adoptable;
714
- }
715
- }
1023
+ const managed = exactOwner.find((record) => this.valuesEqual(intent.type, record.value, intent.value))
1024
+ || exactOwner[0];
716
1025
  if (managed) {
717
1026
  if (!this.valuesEqual(intent.type, managed.value, intent.value) || managed.name !== intent.name) {
718
1027
  const result = await this.dcRouterRef.dnsManager!.updateRecord({
@@ -723,8 +1032,13 @@ export class MailDnsSync {
723
1032
  });
724
1033
  if (!result.success) throw new Error(result.message || `Failed to update ${intent.key}`);
725
1034
  }
1035
+ for (const duplicate of exactOwner.filter((record) => record.id !== managed.id)) {
1036
+ const result = await this.dcRouterRef.dnsManager!.deleteRecord(duplicate.id);
1037
+ if (!result.success) throw new Error(result.message || `Failed to delete duplicate ${intent.key}`);
1038
+ }
726
1039
  intent.providerRecordId = managed.providerRecordId;
727
1040
  intent.status = 'satisfied';
1041
+ intent.satisfactionSource = 'managed';
728
1042
  intent.lastAttemptAt = new Date().toISOString();
729
1043
  return;
730
1044
  }
@@ -745,6 +1059,15 @@ export class MailDnsSync {
745
1059
  intent.lastAttemptAt = new Date().toISOString();
746
1060
  }
747
1061
 
1062
+ private async evaluateTxtRecords(
1063
+ intent: IEmailDnsRecordIntent,
1064
+ values: string[],
1065
+ ): Promise<IMailDnsTxtPolicyResult> {
1066
+ return this.verifier.evaluateTxtRecords
1067
+ ? await this.verifier.evaluateTxtRecords(intent, values)
1068
+ : await evaluateMailTxtIntent(intent, values);
1069
+ }
1070
+
748
1071
  private valuesEqual(type: TDnsRecordType, left: string, right: string): boolean {
749
1072
  if (type === 'MX') return normalizeMx(left) === normalizeMx(right);
750
1073
  if (type === 'A' || type === 'AAAA') return left.trim() === right.trim();
@@ -770,19 +1093,12 @@ export class MailDnsSync {
770
1093
  !intent.error && !zoneErrors.has(intent.domainId)
771
1094
  ));
772
1095
  const markResult = (intent: IEmailDnsRecordIntent, result: IMailDnsVerificationResult) => {
773
- if (result.valid) {
774
- intent.status = 'valid';
775
- intent.validatedAt = new Date().toISOString();
776
- intent.error = undefined;
777
- } else {
778
- intent.status = 'pending';
779
- intent.error = {
780
- code: 'DNS_PROPAGATION_PENDING',
781
- message: result.reason || `${intent.type} ${intent.name} has not propagated`,
782
- recordKey: intent.key,
783
- retryable: true,
784
- };
785
- }
1096
+ const outcome = result.outcome || (result.valid ? 'valid' : 'propagation');
1097
+ applyMailDnsVerificationResult(intent, {
1098
+ outcome,
1099
+ reason: result.reason,
1100
+ effectiveValue: result.effectiveValue,
1101
+ }, 'verify');
786
1102
  };
787
1103
  const mxByName = new Map<string, IEmailDnsRecordIntent[]>();
788
1104
  for (const intent of verifiable.filter((candidate) => candidate.type === 'MX')) {
@@ -793,22 +1109,42 @@ export class MailDnsSync {
793
1109
  await Promise.all([
794
1110
  ...verifiable.filter((intent) => intent.type !== 'MX').map(async (intent) => {
795
1111
  const result = await this.verifier.verifyRecord(intent)
796
- .catch((error: unknown) => ({ valid: false, reason: (error as Error).message }));
1112
+ .catch((error: unknown) => ({
1113
+ valid: false,
1114
+ outcome: 'lookup-error' as const,
1115
+ reason: (error as Error).message,
1116
+ }));
797
1117
  markResult(intent, result);
798
1118
  }),
799
1119
  ...[...mxByName.values()].map(async (intents) => {
800
1120
  const previousMx = (plan.doc.reconciliation?.activeRevision?.mxSlots || [])
801
- .map((slot) => `${slot.priority} ${slot.hostname}`).sort();
802
- const desiredMx = intents.map((intent) => normalizeMx(intent.value)).sort();
803
- const isMakeBeforeBreakTransition = previousMx.length > 0
804
- && JSON.stringify(previousMx) !== JSON.stringify(desiredMx);
805
- const result = await (this.verifier.verifyMxSet && !isMakeBeforeBreakTransition
806
- ? this.verifier.verifyMxSet(intents)
807
- : Promise.all(intents.map((intent) => this.verifier.verifyRecord(intent))).then((results) => ({
808
- valid: results.every((entry) => entry.valid),
809
- reason: results.find((entry) => !entry.valid)?.reason,
810
- })))
811
- .catch((error: unknown) => ({ valid: false, reason: (error as Error).message }));
1121
+ .map((slot) => `${slot.priority} ${slot.hostname}`);
1122
+ const result = await (this.verifier.verifyMxSet
1123
+ ? this.verifier.verifyMxSet(intents, previousMx)
1124
+ : Promise.all(intents.map((intent) => this.verifier.verifyRecord(intent))).then((results) => {
1125
+ const precedence: TEmailDnsVerificationOutcome[] = [
1126
+ 'lookup-error',
1127
+ 'invalid',
1128
+ 'conflict',
1129
+ 'duplicate',
1130
+ 'missing',
1131
+ 'propagation',
1132
+ 'valid',
1133
+ ];
1134
+ const selected = precedence.find((outcome) => results.some((entry) => (
1135
+ (entry.outcome || (entry.valid ? 'valid' : 'propagation')) === outcome
1136
+ ))) || 'propagation';
1137
+ return {
1138
+ valid: selected === 'valid',
1139
+ outcome: selected,
1140
+ reason: results.find((entry) => !entry.valid)?.reason,
1141
+ };
1142
+ }))
1143
+ .catch((error: unknown) => ({
1144
+ valid: false,
1145
+ outcome: 'lookup-error' as const,
1146
+ reason: (error as Error).message,
1147
+ }));
812
1148
  for (const intent of intents) markResult(intent, result);
813
1149
  }),
814
1150
  ]);
@@ -822,7 +1158,11 @@ export class MailDnsSync {
822
1158
  const identityResults = await Promise.all(plan.revision.edgeIdentities.map(async (identity) => ({
823
1159
  identity,
824
1160
  result: await this.verifier.verifyForwardConfirmedIdentity(identity)
825
- .catch((error: unknown) => ({ valid: false, reason: (error as Error).message })),
1161
+ .catch((error: unknown) => ({
1162
+ valid: false,
1163
+ outcome: 'lookup-error' as const,
1164
+ reason: (error as Error).message,
1165
+ })),
826
1166
  })));
827
1167
  for (const { identity, result } of identityResults) {
828
1168
  if (!result.valid) {
@@ -845,8 +1185,28 @@ export class MailDnsSync {
845
1185
  && plan.intents.length > 0
846
1186
  && plan.intents.every((intent) => intent.status === 'valid')
847
1187
  && errors.length === 0;
848
- const hasPermanentError = errors.some((error) => error.retryable === false)
849
- || errors.some((error) => ['DNS_PROVIDER_ZONE_FAILED', 'DNS_UNMANAGED_CONFLICT'].includes(error.code));
1188
+ const hasPermanentError = errors.some((error) => error.retryable === false);
1189
+ const hasOperationalError = errors.some((error) => (
1190
+ error.retryable !== false
1191
+ && (
1192
+ error.code.includes('LOOKUP')
1193
+ || error.code.includes('PROVIDER')
1194
+ || error.code.includes('EGRESS')
1195
+ || error.code.includes('ELIGIBLE')
1196
+ )
1197
+ )) || plan.intents.some((intent) => intent.verificationOutcome === 'lookup-error');
1198
+ const previousRetryAttempt = plan.doc.reconciliation?.retryAttempt || 0;
1199
+ const retryAttempt = canActivate || hasPermanentError
1200
+ ? undefined
1201
+ : hasOperationalError
1202
+ ? previousRetryAttempt + 1
1203
+ : 0;
1204
+ const retryDelay = hasOperationalError
1205
+ ? Math.min(
1206
+ MAIL_DNS_RETRY_INTERVAL_MS * (2 ** Math.max(0, (retryAttempt || 1) - 1)),
1207
+ MAIL_DNS_MAX_RETRY_INTERVAL_MS,
1208
+ )
1209
+ : MAIL_DNS_RETRY_INTERVAL_MS;
850
1210
  const previousActive = plan.doc.reconciliation?.activeRevision;
851
1211
  const topologyChanged = Boolean(previousActive) && JSON.stringify({
852
1212
  selector: previousActive?.selector,
@@ -874,8 +1234,14 @@ export class MailDnsSync {
874
1234
  egressIdentityProof: plan.egressIdentitySupported ? 'supported' : 'unsupported',
875
1235
  selectorCorrectSigning: plan.selectorCorrectSigningSupported ? 'supported' : 'unsupported',
876
1236
  },
1237
+ providerZoneIds: plan.doc.reconciliation?.providerZoneIds || [],
1238
+ retryAttempt,
877
1239
  lastAttemptAt: now,
878
- ...(canActivate ? { lastSuccessAt: now } : { retryAt: new Date(Date.now() + MAIL_DNS_RETRY_INTERVAL_MS).toISOString() }),
1240
+ ...(canActivate
1241
+ ? { lastSuccessAt: now }
1242
+ : hasPermanentError
1243
+ ? {}
1244
+ : { retryAt: new Date(Date.now() + retryDelay).toISOString() }),
879
1245
  };
880
1246
  plan.doc.dnsStatus = {
881
1247
  mx: this.statusForIntent(plan.intents, (intent) => intent.type === 'MX'),
@@ -889,7 +1255,7 @@ export class MailDnsSync {
889
1255
  if (canActivate && topologyChanged) {
890
1256
  // The transition SPF intentionally authorizes old + new identities. Run
891
1257
  // one immediate follow-up to contract it to the newly active revision.
892
- this.dirty = true;
1258
+ this.queueRequest([plan.doc.id], false);
893
1259
  }
894
1260
  }
895
1261
 
@@ -924,11 +1290,76 @@ export class MailDnsSync {
924
1290
  const selected = intents.filter(predicate);
925
1291
  if (selected.length === 0) return 'missing';
926
1292
  if (selected.every((intent) => intent.status === 'valid')) return 'valid';
927
- if (selected.some((intent) => intent.status === 'conflict' || intent.status === 'failed')) return 'invalid';
1293
+ if (selected.some((intent) => (
1294
+ intent.verificationOutcome === 'duplicate'
1295
+ || intent.verificationOutcome === 'conflict'
1296
+ || intent.verificationOutcome === 'invalid'
1297
+ || intent.status === 'conflict'
1298
+ || intent.status === 'failed'
1299
+ ))) return 'invalid';
1300
+ if (selected.some((intent) => intent.verificationOutcome === 'lookup-error')) {
1301
+ return 'unchecked';
1302
+ }
928
1303
  return 'missing';
929
1304
  }
930
1305
 
931
- private async cleanupStaleManagedRecords(zones: DomainDoc[]): Promise<void> {
1306
+ private async refreshProviderZonesForCleanup(
1307
+ docs: EmailDomainDoc[],
1308
+ zones: DomainDoc[],
1309
+ initialProof: Set<string>,
1310
+ ): Promise<Set<string>> {
1311
+ const provenZoneIds = new Set(initialProof);
1312
+ const ownerDocsByZone = new Map<string, Set<EmailDomainDoc>>();
1313
+ for (const doc of docs) {
1314
+ if (!doc.reconciliation) continue;
1315
+ if (doc.reconciliation.providerZoneIds === undefined) {
1316
+ doc.reconciliation.errors.push({
1317
+ code: 'DNS_PROVIDER_PROVENANCE_UNKNOWN',
1318
+ message: 'Provider-zone provenance is unknown; destructive DNS cleanup is blocked',
1319
+ retryable: true,
1320
+ });
1321
+ doc.reconciliation.retryAt = new Date(Date.now() + MAIL_DNS_RETRY_INTERVAL_MS).toISOString();
1322
+ await doc.save();
1323
+ continue;
1324
+ }
1325
+ for (const zoneId of doc.reconciliation.providerZoneIds) {
1326
+ if (provenZoneIds.has(zoneId)) continue;
1327
+ const owners = ownerDocsByZone.get(zoneId) || new Set<EmailDomainDoc>();
1328
+ owners.add(doc);
1329
+ ownerDocsByZone.set(zoneId, owners);
1330
+ }
1331
+ }
1332
+ for (const [zoneId, owners] of ownerDocsByZone) {
1333
+ const zone = zones.find((candidate) => candidate.id === zoneId);
1334
+ try {
1335
+ if (!zone || zone.source !== 'provider') {
1336
+ throw new Error(`Provider zone ${zoneId} is not available`);
1337
+ }
1338
+ const result = await this.dcRouterRef.dnsManager!.syncDomain(zoneId);
1339
+ if (!result.success) throw new Error(result.message || `Failed to refresh ${zone.name}`);
1340
+ provenZoneIds.add(zoneId);
1341
+ } catch (error: unknown) {
1342
+ for (const owner of owners) {
1343
+ if (!owner.reconciliation) continue;
1344
+ owner.reconciliation.errors.push({
1345
+ code: 'DNS_CLEANUP_FAILED',
1346
+ message: `Failed to refresh provider zone ${zone?.name || zoneId} before cleanup: ${(error as Error).message}`,
1347
+ retryable: true,
1348
+ });
1349
+ owner.reconciliation.retryAt = new Date(Date.now() + MAIL_DNS_RETRY_INTERVAL_MS).toISOString();
1350
+ await owner.save();
1351
+ }
1352
+ }
1353
+ }
1354
+ return provenZoneIds;
1355
+ }
1356
+
1357
+ private async cleanupStaleManagedRecords(
1358
+ zones: DomainDoc[],
1359
+ provenProviderZoneIds: Set<string>,
1360
+ selectedOwnerIds: Set<string>,
1361
+ allowTopologyCleanup: boolean,
1362
+ ): Promise<void> {
932
1363
  const docs = await EmailDomainDoc.findAll();
933
1364
  const keep = new Set<string>();
934
1365
  for (const doc of docs) {
@@ -940,7 +1371,14 @@ export class MailDnsSync {
940
1371
  }
941
1372
  const managed = (await DnsRecordDoc.findAll()).filter((record) => record.managedBy === MAIL_DNS_MANAGED_BY);
942
1373
  for (const record of managed) {
1374
+ if (!record.managedOwnerId || !record.managedRecordKey) continue;
1375
+ if (
1376
+ !selectedOwnerIds.has(record.managedOwnerId)
1377
+ && !(allowTopologyCleanup && record.managedOwnerId.startsWith('topology:'))
1378
+ ) continue;
943
1379
  if (record.managedRecordKey && keep.has(record.managedRecordKey)) continue;
1380
+ const zone = zones.find((candidate) => candidate.id === record.domainId);
1381
+ if (zone?.source === 'provider' && !provenProviderZoneIds.has(zone.id)) continue;
944
1382
  try {
945
1383
  const result = await this.dcRouterRef.dnsManager!.deleteRecord(record.id);
946
1384
  if (!result.success) throw new Error(result.message || 'delete failed');
@@ -961,11 +1399,39 @@ export class MailDnsSync {
961
1399
  }
962
1400
  }
963
1401
 
964
- private async finalizeDeletingDomains(): Promise<void> {
965
- const docs = await EmailDomainDoc.findAll();
966
- const records = await DnsRecordDoc.findAll();
967
- for (const doc of docs) {
1402
+ private async finalizeDeletingDomains(
1403
+ deletingDocs: EmailDomainDoc[],
1404
+ zones: DomainDoc[],
1405
+ ): Promise<void> {
1406
+ for (const doc of deletingDocs) {
968
1407
  if (doc.reconciliation?.lifecycleStatus !== 'deleting') continue;
1408
+ if (doc.reconciliation.providerZoneIds === undefined) continue;
1409
+ if (doc.reconciliation.errors.some((error) => (
1410
+ error.code === 'DNS_CLEANUP_FAILED'
1411
+ || error.code === 'DNS_PROVIDER_PROVENANCE_UNKNOWN'
1412
+ ))) continue;
1413
+ let providerProofComplete = true;
1414
+ for (const zoneId of doc.reconciliation.providerZoneIds) {
1415
+ const zone = zones.find((candidate) => candidate.id === zoneId);
1416
+ if (!zone || zone.source !== 'provider') {
1417
+ providerProofComplete = false;
1418
+ break;
1419
+ }
1420
+ const confirmation = await this.dcRouterRef.dnsManager!.syncDomain(zoneId);
1421
+ if (!confirmation.success) {
1422
+ providerProofComplete = false;
1423
+ doc.reconciliation.errors.push({
1424
+ code: 'DNS_CLEANUP_FAILED',
1425
+ message: `Provider zone ${zone.name} could not confirm deletion: ${confirmation.message || 'list failed'}`,
1426
+ retryable: true,
1427
+ });
1428
+ doc.reconciliation.retryAt = new Date(Date.now() + MAIL_DNS_RETRY_INTERVAL_MS).toISOString();
1429
+ await doc.save();
1430
+ break;
1431
+ }
1432
+ }
1433
+ if (!providerProofComplete) continue;
1434
+ const records = await DnsRecordDoc.findAll();
969
1435
  const owned = records.some((record) => (
970
1436
  record.managedBy === MAIL_DNS_MANAGED_BY && record.managedOwnerId === `domain:${doc.id}`
971
1437
  ));