@serve.zone/dcrouter 16.4.1 → 17.0.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 (82) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +852 -852
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/classes.dcrouter.d.ts +5 -1
  5. package/dist_ts/classes.dcrouter.js +42 -9
  6. package/dist_ts/db/documents/classes.dns-record.doc.d.ts +4 -0
  7. package/dist_ts/db/documents/classes.dns-record.doc.js +33 -2
  8. package/dist_ts/db/documents/classes.email-domain.doc.d.ts +5 -1
  9. package/dist_ts/db/documents/classes.email-domain.doc.js +26 -2
  10. package/dist_ts/db/documents/classes.smartmta-storage.doc.d.ts +8 -0
  11. package/dist_ts/db/documents/classes.smartmta-storage.doc.js +91 -0
  12. package/dist_ts/db/documents/index.d.ts +1 -0
  13. package/dist_ts/db/documents/index.js +2 -1
  14. package/dist_ts/dns/manager.dns.d.ts +6 -0
  15. package/dist_ts/dns/manager.dns.js +81 -32
  16. package/dist_ts/dns/providers/cloudflare.provider.d.ts +2 -0
  17. package/dist_ts/dns/providers/cloudflare.provider.js +45 -28
  18. package/dist_ts/email/classes.email-domain.manager.d.ts +43 -38
  19. package/dist_ts/email/classes.email-domain.manager.js +391 -324
  20. package/dist_ts/email/classes.email-settings.manager.js +2 -1
  21. package/dist_ts/email/classes.mail-dns-sync.d.ts +58 -17
  22. package/dist_ts/email/classes.mail-dns-sync.js +857 -141
  23. package/dist_ts/email/classes.mail-edge-eligibility.d.ts +55 -0
  24. package/dist_ts/email/classes.mail-edge-eligibility.js +168 -0
  25. package/dist_ts/email/classes.mail-egress-coordinator.d.ts +3 -1
  26. package/dist_ts/email/classes.mail-egress-coordinator.js +43 -22
  27. package/dist_ts/email/classes.smartmta-blob-storage-manager.d.ts +27 -0
  28. package/dist_ts/email/classes.smartmta-blob-storage-manager.js +109 -0
  29. package/dist_ts/email/classes.smartmta-storage-manager.d.ts +6 -8
  30. package/dist_ts/email/classes.smartmta-storage-manager.js +63 -86
  31. package/dist_ts/email/classes.workapp-mail-manager.d.ts +3 -0
  32. package/dist_ts/email/classes.workapp-mail-manager.js +63 -14
  33. package/dist_ts/email/index.d.ts +3 -0
  34. package/dist_ts/email/index.js +4 -1
  35. package/dist_ts/email/smartmta-storage-policy.d.ts +9 -0
  36. package/dist_ts/email/smartmta-storage-policy.js +58 -0
  37. package/dist_ts/opsserver/handlers/email-domain.handler.d.ts +1 -0
  38. package/dist_ts/opsserver/handlers/email-domain.handler.js +138 -22
  39. package/dist_ts/opsserver/handlers/stats.handler.js +3 -5
  40. package/dist_ts/plugins.d.ts +2 -1
  41. package/dist_ts/plugins.js +3 -2
  42. package/dist_ts/remoteingress/classes.hub-lifecycle.js +2 -1
  43. package/dist_ts/remoteingress/classes.tunnel-manager.d.ts +6 -0
  44. package/dist_ts/remoteingress/classes.tunnel-manager.js +40 -2
  45. package/dist_ts_interfaces/data/dns-record.d.ts +6 -0
  46. package/dist_ts_interfaces/data/email-domain.d.ts +81 -0
  47. package/dist_ts_interfaces/requests/email-domains.d.ts +25 -17
  48. package/dist_ts_migrations/index.d.ts +2 -0
  49. package/dist_ts_migrations/index.js +441 -16
  50. package/dist_ts_web/00_commitinfo_data.js +1 -1
  51. package/dist_ts_web/appstate/email-domains.d.ts +16 -0
  52. package/dist_ts_web/appstate/email-domains.js +140 -19
  53. package/dist_ts_web/elements/email/ops-view-email-domains.d.ts +1 -0
  54. package/dist_ts_web/elements/email/ops-view-email-domains.js +36 -13
  55. package/package.json +4 -3
  56. package/readme.md +13 -1
  57. package/ts/00_commitinfo_data.ts +1 -1
  58. package/ts/classes.dcrouter.ts +55 -11
  59. package/ts/db/documents/classes.dns-record.doc.ts +20 -0
  60. package/ts/db/documents/classes.email-domain.doc.ts +14 -0
  61. package/ts/db/documents/classes.smartmta-storage.doc.ts +25 -0
  62. package/ts/db/documents/index.ts +1 -0
  63. package/ts/dns/manager.dns.ts +89 -29
  64. package/ts/dns/providers/cloudflare.provider.ts +46 -27
  65. package/ts/email/classes.email-domain.manager.ts +416 -368
  66. package/ts/email/classes.email-settings.manager.ts +1 -0
  67. package/ts/email/classes.mail-dns-sync.ts +913 -146
  68. package/ts/email/classes.mail-edge-eligibility.ts +205 -0
  69. package/ts/email/classes.mail-egress-coordinator.ts +46 -23
  70. package/ts/email/classes.smartmta-blob-storage-manager.ts +138 -0
  71. package/ts/email/classes.smartmta-storage-manager.ts +86 -83
  72. package/ts/email/classes.workapp-mail-manager.ts +82 -16
  73. package/ts/email/index.ts +3 -0
  74. package/ts/email/smartmta-storage-policy.ts +73 -0
  75. package/ts/opsserver/handlers/email-domain.handler.ts +154 -21
  76. package/ts/opsserver/handlers/stats.handler.ts +2 -4
  77. package/ts/plugins.ts +2 -1
  78. package/ts/remoteingress/classes.hub-lifecycle.ts +1 -0
  79. package/ts/remoteingress/classes.tunnel-manager.ts +53 -7
  80. package/ts_web/00_commitinfo_data.ts +1 -1
  81. package/ts_web/appstate/email-domains.ts +187 -18
  82. package/ts_web/elements/email/ops-view-email-domains.ts +44 -12
@@ -1,218 +1,985 @@
1
+ import * as plugins from '../plugins.js';
1
2
  import { logger } from '../logger.js';
2
3
  import { DnsRecordDoc } from '../db/documents/classes.dns-record.doc.js';
3
4
  import { DomainDoc } from '../db/documents/classes.domain.doc.js';
4
5
  import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
5
6
  import type { DcRouter } from '../classes.dcrouter.js';
6
- import type { IRemoteIngress } from '../../ts_interfaces/data/remoteingress.js';
7
+ import type {
8
+ IEmailDnsRecordIntent,
9
+ IEmailDomainDnsRevision,
10
+ IEmailDomainEdgeIdentity,
11
+ IEmailDomainMxSlot,
12
+ IEmailDomainOperationError,
13
+ IEmailDkimMaterial,
14
+ } from '../../ts_interfaces/data/email-domain.js';
15
+ import type { TDnsRecordType } from '../../ts_interfaces/data/dns-record.js';
16
+ import {
17
+ MailEdgeEligibility,
18
+ ReleasedRemoteIngressEgressIdentitySource,
19
+ type IEligibleMailEdge,
20
+ } from './classes.mail-edge-eligibility.js';
7
21
 
8
- export const MAIL_DNS_SYNC_CREATED_BY = 'mail-edge-sync';
22
+ export const MAIL_DNS_SYNC_CREATED_BY = 'mail-dns-reconciler';
23
+ export const MAIL_DNS_MANAGED_BY = 'mail-dns-reconciler';
24
+ export const MAIL_DNS_RETRY_INTERVAL_MS = 5 * 60_000;
9
25
 
10
- interface IDesiredMailDnsRecord {
11
- name: string;
12
- type: 'A' | 'AAAA';
13
- value: string;
26
+
27
+ export interface IMailDnsVerificationResult {
28
+ valid: boolean;
29
+ reason?: string;
30
+ }
31
+
32
+ export interface IMailDnsVerifier {
33
+ verifyRecord(intent: IEmailDnsRecordIntent): Promise<IMailDnsVerificationResult>;
34
+ verifyMxSet?(intents: IEmailDnsRecordIntent[]): Promise<IMailDnsVerificationResult>;
35
+ verifyForwardConfirmedIdentity(
36
+ identity: IEmailDomainEdgeIdentity,
37
+ ): Promise<IMailDnsVerificationResult>;
38
+ destroy?(): void;
39
+ }
40
+
41
+ function normalizeHostname(value: string): string {
42
+ return value.trim().toLowerCase().replace(/\.$/, '');
43
+ }
44
+
45
+ function normalizeMx(value: string): string {
46
+ const [priority, exchange = ''] = value.trim().split(/\s+/);
47
+ return `${Number(priority)} ${normalizeHostname(exchange)}`;
48
+ }
49
+
50
+ function expandIpv6(address: string): string[] | undefined {
51
+ let normalized = address.toLowerCase();
52
+ if (normalized.includes('.')) {
53
+ const lastColon = normalized.lastIndexOf(':');
54
+ const ipv4 = normalized.slice(lastColon + 1).split('.').map(Number);
55
+ if (ipv4.length !== 4 || ipv4.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
56
+ return undefined;
57
+ }
58
+ normalized = `${normalized.slice(0, lastColon)}:${((ipv4[0] << 8) | ipv4[1]).toString(16)}:${((ipv4[2] << 8) | ipv4[3]).toString(16)}`;
59
+ }
60
+ const halves = normalized.split('::');
61
+ if (halves.length > 2) return undefined;
62
+ const left = halves[0] ? halves[0].split(':') : [];
63
+ const right = halves[1] ? halves[1].split(':') : [];
64
+ const missing = 8 - left.length - right.length;
65
+ if ((halves.length === 1 && missing !== 0) || missing < 0) return undefined;
66
+ const groups = [...left, ...Array.from({ length: missing }, () => '0'), ...right];
67
+ if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/.test(group))) return undefined;
68
+ return groups.map((group) => group.padStart(4, '0'));
69
+ }
70
+
71
+ export function toReverseDnsName(address: string): string {
72
+ const family = plugins.smartnetwork.getIpVersion(address);
73
+ if (family === 4) {
74
+ return `${address.split('.').reverse().join('.')}.in-addr.arpa`;
75
+ }
76
+ if (family === 6) {
77
+ const groups = expandIpv6(address);
78
+ if (!groups) throw new Error(`Invalid IPv6 address: ${address}`);
79
+ return `${groups.join('').split('').reverse().join('.')}.ip6.arpa`;
80
+ }
81
+ throw new Error(`Invalid IP address: ${address}`);
82
+ }
83
+
84
+ export class SmartDnsMailVerifier implements IMailDnsVerifier {
85
+ private client?: plugins.smartdns.dnsClientMod.Smartdns;
86
+
87
+ private getClient(): plugins.smartdns.dnsClientMod.Smartdns {
88
+ this.client ||= new plugins.smartdns.dnsClientMod.Smartdns({});
89
+ return this.client;
90
+ }
91
+
92
+ public async verifyRecord(intent: IEmailDnsRecordIntent): Promise<IMailDnsVerificationResult> {
93
+ try {
94
+ const records = await this.getClient().getRecords(intent.name, intent.type);
95
+ const values = (records || []).map((record) => String(record.value).trim());
96
+ 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` };
100
+ }
101
+ 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` };
111
+ }
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` };
116
+ } catch (error: unknown) {
117
+ return { valid: false, reason: `DNS lookup failed: ${(error as Error).message}` };
118
+ }
119
+ }
120
+
121
+ public async verifyMxSet(intents: IEmailDnsRecordIntent[]): Promise<IMailDnsVerificationResult> {
122
+ if (intents.length === 0) return { valid: false, reason: 'No desired MX records' };
123
+ 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
+ };
133
+ } catch (error: unknown) {
134
+ return { valid: false, reason: `MX lookup failed: ${(error as Error).message}` };
135
+ }
136
+ }
137
+
138
+ public async verifyForwardConfirmedIdentity(
139
+ identity: IEmailDomainEdgeIdentity,
140
+ ): Promise<IMailDnsVerificationResult> {
141
+ const checks: Array<{ family: 'A' | 'AAAA'; address: string }> = [];
142
+ if (identity.ipv4) checks.push({ family: 'A', address: identity.ipv4 });
143
+ if (identity.ipv6) checks.push({ family: 'AAAA', address: identity.ipv6 });
144
+ try {
145
+ 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)));
148
+ if (ptrNames.length !== 1 || ptrNames[0] !== normalizeHostname(identity.hostname)) {
149
+ return {
150
+ valid: false,
151
+ reason: `PTR for ${check.address} must be exactly ${identity.hostname}`,
152
+ };
153
+ }
154
+ const forwardRecords = await this.getClient().getRecords(identity.hostname, check.family);
155
+ const forwardIps = (forwardRecords || []).map((record) => String(record.value));
156
+ if (!forwardIps.includes(check.address)) {
157
+ return {
158
+ valid: false,
159
+ reason: `${check.family} ${identity.hostname} does not resolve back to ${check.address}`,
160
+ };
161
+ }
162
+ }
163
+ return checks.length > 0
164
+ ? { valid: true }
165
+ : { valid: false, reason: `No source-bound address is available for ${identity.edgeId}` };
166
+ } catch (error: unknown) {
167
+ return { valid: false, reason: `FCrDNS lookup failed: ${(error as Error).message}` };
168
+ }
169
+ }
170
+
171
+ public destroy(): void {
172
+ this.client?.destroy();
173
+ this.client = undefined;
174
+ }
175
+ }
176
+
177
+ interface IPlannedDomain {
178
+ doc: EmailDomainDoc;
179
+ revision: IEmailDomainDnsRevision;
180
+ intents: IEmailDnsRecordIntent[];
181
+ preliminaryErrors: IEmailDomainOperationError[];
182
+ egressIdentitySupported: boolean;
183
+ selectorCorrectSigningSupported: boolean;
14
184
  }
15
185
 
16
186
  /**
17
- * Reconciles the mail-facing A/AAAA records that make edges — never the hub —
18
- * the public identity of the mail platform:
19
- * (a) shared mail hostname → publicIp/publicIpV6 of every enabled mail edge,
20
- * (b) each edge's mailHostname → that edge's IPs (FCrDNS for outbound EHLO),
21
- * (c) mx.<domain> → the pinned edges' IPs for every pinned email domain.
22
- * Records are DB-backed DnsRecordDoc rows owned via createdBy 'mail-edge-sync';
23
- * provider-hosted zones (e.g. Cloudflare) are written through DnsManager's
24
- * provider path, dcrouter-authoritative zones serve immediately.
187
+ * Sole owner of managed mail DNS. API, topology, timer, and startup events only
188
+ * mark this globally serialized reconciler dirty.
25
189
  */
26
190
  export class MailDnsSync {
27
- private syncInFlight?: Promise<void>;
28
- private resyncQueued = false;
191
+ private started = false;
192
+ private stopped = false;
193
+ private dirty = false;
194
+ private runPromise?: Promise<void>;
195
+ private retryTimer?: ReturnType<typeof setInterval>;
196
+ private edgeEligibility: MailEdgeEligibility;
197
+
198
+ constructor(
199
+ private dcRouterRef: DcRouter,
200
+ edgeEligibility?: MailEdgeEligibility,
201
+ private verifier: IMailDnsVerifier = new SmartDnsMailVerifier(),
202
+ ) {
203
+ this.edgeEligibility = edgeEligibility
204
+ || dcRouterRef.mailEdgeEligibility
205
+ || new MailEdgeEligibility(dcRouterRef, new ReleasedRemoteIngressEgressIdentitySource(dcRouterRef));
206
+ }
207
+
208
+ public async start(): Promise<void> {
209
+ if (this.started) {
210
+ await this.runPromise;
211
+ return;
212
+ }
213
+ this.started = true;
214
+ this.stopped = false;
215
+ this.retryTimer = setInterval(() => this.requestSync('periodic retry'), MAIL_DNS_RETRY_INTERVAL_MS);
216
+ this.dirty = true;
217
+ try {
218
+ await this.kick('startup');
219
+ } catch (error: unknown) {
220
+ if (this.retryTimer) clearInterval(this.retryTimer);
221
+ this.retryTimer = undefined;
222
+ this.started = false;
223
+ this.dirty = false;
224
+ throw error;
225
+ }
226
+ }
29
227
 
30
- constructor(private dcRouterRef: DcRouter) {}
228
+ public async stop(): Promise<void> {
229
+ this.stopped = true;
230
+ this.started = false;
231
+ this.dirty = false;
232
+ if (this.retryTimer) {
233
+ clearInterval(this.retryTimer);
234
+ this.retryTimer = undefined;
235
+ }
236
+ await this.runPromise?.catch(() => undefined);
237
+ this.verifier.destroy?.();
238
+ }
31
239
 
32
- /** Fire-and-forget trigger for CRUD hooks; failures are logged, not thrown. */
33
240
  public requestSync(reason: string): void {
34
- this.sync(reason).catch((err: unknown) => {
35
- logger.log('error', `MailDnsSync failed (${reason}): ${(err as Error).message}`);
241
+ if (this.stopped) return;
242
+ this.dirty = true;
243
+ if (!this.started) return;
244
+ this.kick(reason).catch((error: unknown) => {
245
+ logger.log('error', `MailDnsSync failed (${reason}): ${(error as Error).message}`);
36
246
  });
37
247
  }
38
248
 
39
- /** Coalescing entry point: concurrent triggers fold into one follow-up pass. */
249
+ /** Coalescing entry point used by API actions that need the durable outcome. */
40
250
  public async sync(reason: string): Promise<void> {
41
- if (this.syncInFlight) {
42
- this.resyncQueued = true;
43
- return this.syncInFlight;
44
- }
45
- this.syncInFlight = this.reconcile(reason).finally(() => {
46
- this.syncInFlight = undefined;
47
- if (this.resyncQueued) {
48
- this.resyncQueued = false;
49
- this.requestSync(`${reason} (coalesced)`);
251
+ if (this.stopped) throw new Error('MailDnsSync is stopped');
252
+ this.dirty = true;
253
+ await this.kick(reason);
254
+ }
255
+
256
+ private async kick(reason: string): Promise<void> {
257
+ if (this.runPromise) return await this.runPromise;
258
+ // Defer execution one microtask so runPromise is assigned before the first
259
+ // awaited provider/eligibility hook can synchronously enqueue another event.
260
+ this.runPromise = Promise.resolve().then(() => this.runLoop(reason, false)).finally(() => {
261
+ this.runPromise = undefined;
262
+ if (this.dirty && this.started && !this.stopped) {
263
+ this.requestSync(`${reason} (follow-up)`);
50
264
  }
51
265
  });
52
- return this.syncInFlight;
266
+ return await this.runPromise;
267
+ }
268
+
269
+ private async runLoop(reason: string, singlePass: boolean): Promise<void> {
270
+ do {
271
+ this.dirty = false;
272
+ await this.reconcile(reason);
273
+ if (singlePass) break;
274
+ // An event during the run leaves dirty=true and forces exactly one
275
+ // immediate follow-up; repeated events coalesce into that pass.
276
+ } while (this.dirty && !this.stopped);
53
277
  }
54
278
 
55
279
  private async reconcile(reason: string): Promise<void> {
56
- if (this.dcRouterRef.options.dbConfig?.enabled === false) {
57
- return;
58
- }
280
+ if (this.dcRouterRef.options.dbConfig?.enabled === false) return;
59
281
  const dnsManager = this.dcRouterRef.dnsManager;
60
- const remoteIngressManager = this.dcRouterRef.remoteIngressManager;
61
- if (!dnsManager || !remoteIngressManager) {
62
- // Without both managers the edge registry / record store is not
63
- // authoritative — skip entirely rather than delete managed records.
282
+ if (!dnsManager) {
283
+ logger.log('warn', `MailDnsSync (${reason}): DnsManager unavailable; retry intent retained`);
64
284
  return;
65
285
  }
66
286
 
67
- const desired = await this.computeDesiredRecords();
68
- const zones = await DomainDoc.findAll();
287
+ // Refresh the broad operational status independently from per-domain pins.
288
+ await this.edgeEligibility.resolveForDomain();
69
289
 
70
- // Place each desired record in its zone (longest-suffix match). Records
71
- // without a managed zone are reported — the operator must onboard the zone.
72
- const placed = new Map<string, { domainId: string; record: IDesiredMailDnsRecord }>();
73
- const unplacedNames = new Set<string>();
74
- for (const record of desired) {
75
- const zone = this.findZoneForName(record.name, zones);
76
- if (!zone) {
77
- unplacedNames.add(record.name);
290
+ const zones = await DomainDoc.findAll();
291
+ const docs = await EmailDomainDoc.findAll();
292
+ const plans: IPlannedDomain[] = [];
293
+ for (const loadedDoc of docs) {
294
+ const doc = await this.dcRouterRef.emailDomainManager?.stageDkimRotationIfDue?.(loadedDoc.id)
295
+ || loadedDoc;
296
+ if (doc.reconciliation?.lifecycleStatus === 'deleting') {
297
+ await this.persistDeletingAttempt(doc);
78
298
  continue;
79
299
  }
80
- placed.set(`${zone.id}|${record.name}|${record.type}|${record.value}`, {
81
- domainId: zone.id,
82
- record,
300
+ const plan = await this.buildPlan(doc, zones);
301
+ await this.persistDesiredPlan(plan);
302
+ plans.push(plan);
303
+ }
304
+
305
+ const uniqueIntents = new Map<string, IEmailDnsRecordIntent>();
306
+ for (const plan of plans) {
307
+ for (const intent of plan.intents) {
308
+ uniqueIntents.set(intent.key, intent);
309
+ }
310
+ }
311
+
312
+ const zoneErrors = await this.reconcileZones([...uniqueIntents.values()], zones);
313
+ for (const plan of plans) {
314
+ const errors = [...plan.preliminaryErrors];
315
+ for (const intent of plan.intents) {
316
+ const outcome = uniqueIntents.get(intent.key);
317
+ if (outcome) Object.assign(intent, outcome);
318
+ if (intent.error) errors.push(intent.error);
319
+ }
320
+ await this.validateAndActivate(plan, errors, zoneErrors);
321
+ }
322
+
323
+ await this.cleanupStaleManagedRecords(zones);
324
+ await this.finalizeDeletingDomains();
325
+ await this.dcRouterRef.emailDomainManager?.syncManagedDomainsToRuntime();
326
+ await this.dcRouterRef.workAppMailManager?.applyStoredIdentitiesToRuntime();
327
+ }
328
+
329
+ private async buildPlan(doc: EmailDomainDoc, zones: DomainDoc[]): Promise<IPlannedDomain> {
330
+ const now = new Date().toISOString();
331
+ const eligibility = await this.edgeEligibility.resolveForDomain(doc);
332
+ const preliminaryErrors: IEmailDomainOperationError[] = [
333
+ ...(doc.reconciliation?.errors || []).filter((error) => error.code === 'DKIM_ROTATION_STAGE_FAILED'),
334
+ ];
335
+ if (!eligibility.supported) {
336
+ preliminaryErrors.push({
337
+ code: 'EGRESS_IDENTITY_PROOF_UNAVAILABLE',
338
+ message: eligibility.reason || 'RemoteIngress egress identity proof is unavailable',
339
+ retryable: true,
340
+ });
341
+ } else if (eligibility.edges.length === 0) {
342
+ preliminaryErrors.push({
343
+ code: 'NO_ELIGIBLE_MAIL_EDGE',
344
+ message: eligibility.reason || 'No eligible mail edge is connected',
345
+ retryable: true,
83
346
  });
84
347
  }
85
- for (const name of unplacedNames) {
86
- logger.log('warn', `MailDnsSync: no managed DNS zone covers ${name} — record skipped`);
348
+
349
+ const activeDkim = this.getActiveDkim(doc);
350
+ const upstreamSelectorCapability = this.dcRouterRef.emailDomainManager?.supportsSelectorCorrectSigning?.() === true;
351
+ const selectorCorrectSigningSupported = (
352
+ (!activeDkim || activeDkim.selector === 'default') && !doc.pendingDkim
353
+ ) || upstreamSelectorCapability;
354
+ if (!activeDkim) {
355
+ preliminaryErrors.push({
356
+ code: 'DKIM_KEY_UNAVAILABLE',
357
+ message: `No usable active DKIM key exists for ${doc.domain}`,
358
+ retryable: false,
359
+ });
360
+ } else if (!selectorCorrectSigningSupported) {
361
+ preliminaryErrors.push({
362
+ code: 'SMARTMTA_SELECTOR_SIGNING_UNSUPPORTED',
363
+ message: 'The consumed SmartMTA release cannot safely sign with a non-default selector',
364
+ retryable: false,
365
+ });
87
366
  }
88
367
 
89
- const existing = (await DnsRecordDoc.findAll())
90
- .filter((record) => record.createdBy === MAIL_DNS_SYNC_CREATED_BY);
368
+ const edgeIdentities = eligibility.edges.map((candidate) => this.toDomainEdgeIdentity(candidate));
369
+ const mxSlots = this.allocateMxSlots(doc, eligibility.edges);
370
+ if (eligibility.edges.length >= 2 && mxSlots.length < 2) {
371
+ preliminaryErrors.push({
372
+ code: 'INSUFFICIENT_DISTINCT_MX_IDENTITIES',
373
+ message: 'At least two eligible mail edges exist but they do not provide two distinct MX hostnames',
374
+ retryable: false,
375
+ });
376
+ }
377
+ const revisionId = plugins.smartunique.shortId();
378
+ const intents: IEmailDnsRecordIntent[] = [];
91
379
 
92
- let created = 0;
93
- let deleted = 0;
380
+ for (const candidate of eligibility.edges) {
381
+ for (const identity of candidate.identities) {
382
+ const type = identity.family === 4 ? 'A' : 'AAAA';
383
+ intents.push(this.intentForName(zones, {
384
+ key: `mail-topology:${candidate.edge.id}:${type}:${candidate.hostname}:${identity.address}`,
385
+ ownerType: 'topology',
386
+ ownerId: `topology:${candidate.edge.id}`,
387
+ name: candidate.hostname,
388
+ type,
389
+ value: identity.address,
390
+ ttl: 300,
391
+ }));
392
+ }
393
+ }
94
394
 
95
- for (const record of existing) {
96
- const key = `${record.domainId}|${record.name}|${record.type}|${record.value}`;
97
- if (placed.has(key)) {
98
- placed.delete(key); // already present — nothing to create
99
- continue;
395
+ if (eligibility.edges.length > 0) {
396
+ for (const slot of mxSlots) {
397
+ intents.push(this.intentForName(zones, {
398
+ key: `mail-domain:${doc.id}:mx:${slot.priority}:${slot.edgeId}:${slot.hostname}`,
399
+ ownerType: 'domain',
400
+ ownerId: `domain:${doc.id}`,
401
+ name: doc.domain,
402
+ type: 'MX',
403
+ value: `${slot.priority} ${slot.hostname}`,
404
+ ttl: 300,
405
+ }));
100
406
  }
101
- const result = await dnsManager.deleteRecord(record.id);
102
- if (result.success) {
103
- deleted++;
104
- } else {
105
- logger.log('warn', `MailDnsSync: failed to delete stale ${record.type} ${record.name} → ${record.value}: ${result.message}`);
407
+ const spfIdentities = [
408
+ ...edgeIdentities,
409
+ ...(doc.reconciliation?.activeRevision?.edgeIdentities || []),
410
+ ];
411
+ intents.push(this.intentForName(zones, {
412
+ key: `mail-domain:${doc.id}:spf`,
413
+ ownerType: 'domain',
414
+ ownerId: `domain:${doc.id}`,
415
+ name: doc.domain,
416
+ type: 'TXT',
417
+ value: this.buildSpfValue(spfIdentities),
418
+ ttl: 300,
419
+ }));
420
+ }
421
+
422
+ if (activeDkim) intents.push(this.buildDkimIntent(doc, activeDkim, zones));
423
+ if (doc.pendingDkim) intents.push(this.buildDkimIntent(doc, doc.pendingDkim, zones));
424
+ for (const retiring of doc.retiringDkim || []) {
425
+ if (!retiring.retireAfter || Date.parse(retiring.retireAfter) > Date.now()) {
426
+ intents.push(this.buildDkimIntent(doc, retiring, zones));
106
427
  }
107
428
  }
429
+ intents.push(this.intentForName(zones, {
430
+ key: `mail-domain:${doc.id}:dmarc`,
431
+ ownerType: 'domain',
432
+ ownerId: `domain:${doc.id}`,
433
+ name: `_dmarc.${doc.domain}`,
434
+ type: 'TXT',
435
+ value: `v=DMARC1; p=none; rua=mailto:dmarc@${doc.domain}`,
436
+ ttl: 3600,
437
+ }));
108
438
 
109
- for (const { domainId, record } of placed.values()) {
110
- const result = await dnsManager.createRecord({
111
- domainId,
112
- name: record.name,
113
- type: record.type,
114
- value: record.value,
115
- // Short TTL: edge IPs are burnable identities and failover heals via DNS.
116
- ttl: 300,
117
- createdBy: MAIL_DNS_SYNC_CREATED_BY,
118
- });
119
- if (result.success) {
120
- created++;
121
- } else {
122
- logger.log('warn', `MailDnsSync: failed to create ${record.type} ${record.name} → ${record.value}: ${result.message}`);
439
+ for (const intent of intents) {
440
+ if (!intent.domainId) {
441
+ intent.status = 'failed';
442
+ intent.error = {
443
+ code: 'DNS_ZONE_UNMANAGED',
444
+ message: `No managed DNS zone covers ${intent.name}`,
445
+ recordKey: intent.key,
446
+ retryable: false,
447
+ };
123
448
  }
124
449
  }
450
+ const revision: IEmailDomainDnsRevision = {
451
+ id: revisionId,
452
+ createdAt: now,
453
+ selector: doc.pendingDkim?.selector || activeDkim?.selector || doc.dkim.selector || 'default',
454
+ edgeIdentities,
455
+ mxSlots,
456
+ recordKeys: intents.map((intent) => intent.key),
457
+ };
458
+ return {
459
+ doc,
460
+ revision,
461
+ intents,
462
+ preliminaryErrors,
463
+ egressIdentitySupported: eligibility.supported,
464
+ selectorCorrectSigningSupported,
465
+ };
466
+ }
467
+
468
+ private getActiveDkim(doc: EmailDomainDoc): IEmailDkimMaterial | undefined {
469
+ if (doc.activeDkim?.publicKey) return doc.activeDkim;
470
+ const publicKey = doc.dkim?.publicKey?.trim();
471
+ if (!publicKey) return undefined;
472
+ return {
473
+ selector: doc.dkim.selector || 'default',
474
+ keySize: doc.dkim.keySize || 2048,
475
+ publicKey,
476
+ createdAt: doc.createdAt,
477
+ };
478
+ }
125
479
 
126
- if (created > 0 || deleted > 0) {
127
- logger.log('info', `MailDnsSync (${reason}): ${created} record(s) created, ${deleted} deleted`);
480
+ private buildDkimIntent(
481
+ doc: EmailDomainDoc,
482
+ material: IEmailDkimMaterial,
483
+ zones: DomainDoc[],
484
+ ): IEmailDnsRecordIntent {
485
+ return this.intentForName(zones, {
486
+ key: `mail-domain:${doc.id}:dkim:${material.selector}`,
487
+ ownerType: 'domain',
488
+ ownerId: `domain:${doc.id}`,
489
+ name: `${material.selector}._domainkey.${doc.domain}`,
490
+ type: 'TXT',
491
+ value: `v=DKIM1; h=sha256; k=rsa; p=${material.publicKey}`,
492
+ ttl: 3600,
493
+ });
494
+ }
495
+
496
+ private intentForName(
497
+ zones: DomainDoc[],
498
+ input: Omit<IEmailDnsRecordIntent, 'domainId' | 'status'>,
499
+ ): IEmailDnsRecordIntent {
500
+ return {
501
+ ...input,
502
+ domainId: this.findZoneForName(input.name, zones)?.id || '',
503
+ status: 'pending',
504
+ };
505
+ }
506
+
507
+ private toDomainEdgeIdentity(candidate: IEligibleMailEdge): IEmailDomainEdgeIdentity {
508
+ const ipv4 = candidate.identities.find((identity) => identity.family === 4);
509
+ const ipv6 = candidate.identities.find((identity) => identity.family === 6);
510
+ const observedAt = Math.min(...candidate.identities.map((identity) => identity.observedAt));
511
+ const hubReceivedAt = Math.min(...candidate.identities.map((identity) => identity.hubReceivedAt));
512
+ return {
513
+ edgeId: candidate.edge.id,
514
+ hostname: candidate.hostname,
515
+ ...(ipv4 ? { ipv4: ipv4.address } : {}),
516
+ ...(ipv6 ? { ipv6: ipv6.address } : {}),
517
+ observedAt: new Date(observedAt).toISOString(),
518
+ hubReceivedAt: new Date(hubReceivedAt).toISOString(),
519
+ };
520
+ }
521
+
522
+ private allocateMxSlots(doc: EmailDomainDoc, candidates: IEligibleMailEdge[]): IEmailDomainMxSlot[] {
523
+ if (candidates.length === 1) {
524
+ return [{
525
+ priority: 10,
526
+ edgeId: candidates[0].edge.id,
527
+ hostname: candidates[0].hostname,
528
+ }];
529
+ }
530
+ const byId = new Map(candidates.map((candidate) => [candidate.edge.id, candidate]));
531
+ const result: IEmailDomainMxSlot[] = [];
532
+ const usedEdges = new Set<string>();
533
+ const usedHostnames = new Set<string>();
534
+ const previous = [
535
+ ...(doc.reconciliation?.activeRevision?.mxSlots || []),
536
+ ...(doc.reconciliation?.pendingRevision?.mxSlots || []),
537
+ ];
538
+ for (const priority of [10, 20] as const) {
539
+ const old = previous.find((slot) => slot.priority === priority);
540
+ const candidate = old ? byId.get(old.edgeId) : undefined;
541
+ if (!candidate || candidate.hostname !== old?.hostname) continue;
542
+ if (usedEdges.has(candidate.edge.id) || usedHostnames.has(candidate.hostname)) continue;
543
+ result.push({ priority, edgeId: candidate.edge.id, hostname: candidate.hostname });
544
+ usedEdges.add(candidate.edge.id);
545
+ usedHostnames.add(candidate.hostname);
128
546
  }
547
+ for (const priority of [10, 20] as const) {
548
+ if (result.some((slot) => slot.priority === priority)) continue;
549
+ const candidate = candidates.find((entry) => (
550
+ !usedEdges.has(entry.edge.id) && !usedHostnames.has(entry.hostname)
551
+ ));
552
+ if (!candidate) continue;
553
+ result.push({ priority, edgeId: candidate.edge.id, hostname: candidate.hostname });
554
+ usedEdges.add(candidate.edge.id);
555
+ usedHostnames.add(candidate.hostname);
556
+ }
557
+ return result.sort((left, right) => left.priority - right.priority);
129
558
  }
130
559
 
131
- private async computeDesiredRecords(): Promise<IDesiredMailDnsRecord[]> {
132
- const remoteIngressManager = this.dcRouterRef.remoteIngressManager!;
133
- const desired: IDesiredMailDnsRecord[] = [];
560
+ private buildSpfValue(identities: IEmailDomainEdgeIdentity[]): string {
561
+ const terms = new Set<string>();
562
+ for (const identity of identities) {
563
+ if (identity.ipv4) terms.add(`ip4:${identity.ipv4}`);
564
+ if (identity.ipv6) terms.add(`ip6:${identity.ipv6}`);
565
+ }
566
+ return `v=spf1 ${[...terms].sort().join(' ')} -all`.replace(/\s+/g, ' ').trim();
567
+ }
134
568
 
135
- const pushEdgeIps = (name: string | undefined, edge: IRemoteIngress) => {
136
- if (!name) return;
137
- const normalizedName = name.toLowerCase();
138
- if (edge.publicIp) {
139
- desired.push({ name: normalizedName, type: 'A', value: edge.publicIp });
140
- }
141
- if (edge.publicIpV6) {
142
- desired.push({ name: normalizedName, type: 'AAAA', value: edge.publicIpV6 });
569
+ private async persistDesiredPlan(plan: IPlannedDomain): Promise<void> {
570
+ const previous = plan.doc.reconciliation;
571
+ const previousSignature = JSON.stringify((previous?.intents || []).map((intent) => [intent.key, intent.value]));
572
+ const nextSignature = JSON.stringify(plan.intents.map((intent) => [intent.key, intent.value]));
573
+ const generation = (previous?.desiredGeneration || 0) + (previousSignature === nextSignature ? 0 : 1);
574
+ plan.doc.reconciliation = {
575
+ lifecycleStatus: previous?.lifecycleStatus === 'active' ? 'active' : 'pending',
576
+ desiredGeneration: Math.max(generation, 1),
577
+ activeRevision: previous?.activeRevision,
578
+ pendingRevision: plan.revision,
579
+ intents: plan.intents,
580
+ errors: plan.preliminaryErrors,
581
+ capability: {
582
+ egressIdentityProof: plan.egressIdentitySupported ? 'supported' : 'unsupported',
583
+ selectorCorrectSigning: plan.selectorCorrectSigningSupported ? 'supported' : 'unsupported',
584
+ },
585
+ lastAttemptAt: new Date().toISOString(),
586
+ retryAt: new Date(Date.now() + MAIL_DNS_RETRY_INTERVAL_MS).toISOString(),
587
+ };
588
+ plan.doc.updatedAt = new Date().toISOString();
589
+ await plan.doc.save();
590
+ }
591
+
592
+ private async persistDeletingAttempt(doc: EmailDomainDoc): Promise<void> {
593
+ doc.reconciliation = {
594
+ ...(doc.reconciliation || {
595
+ lifecycleStatus: 'deleting',
596
+ desiredGeneration: 1,
597
+ intents: [],
598
+ errors: [],
599
+ }),
600
+ lifecycleStatus: 'deleting',
601
+ intents: [],
602
+ errors: (doc.reconciliation?.errors || []).filter((error) => error.code !== 'DNS_CLEANUP_FAILED'),
603
+ lastAttemptAt: new Date().toISOString(),
604
+ retryAt: new Date(Date.now() + MAIL_DNS_RETRY_INTERVAL_MS).toISOString(),
605
+ };
606
+ await doc.save();
607
+ }
608
+
609
+ private async reconcileZones(
610
+ intents: IEmailDnsRecordIntent[],
611
+ zones: DomainDoc[],
612
+ ): Promise<Map<string, IEmailDomainOperationError>> {
613
+ const errors = new Map<string, IEmailDomainOperationError>();
614
+ const byZone = new Map<string, IEmailDnsRecordIntent[]>();
615
+ for (const intent of intents) {
616
+ if (!intent.domainId || intent.error) continue;
617
+ const group = byZone.get(intent.domainId) || [];
618
+ group.push(intent);
619
+ byZone.set(intent.domainId, group);
620
+ }
621
+ for (const [zoneId, zoneIntents] of byZone) {
622
+ const zone = zones.find((candidate) => candidate.id === zoneId);
623
+ if (!zone) continue;
624
+ try {
625
+ if (zone.source === 'provider') {
626
+ const result = await this.dcRouterRef.dnsManager!.syncDomain(zone.id);
627
+ if (!result.success) throw new Error(result.message || `Failed to refresh ${zone.name}`);
628
+ }
629
+ for (const intent of this.sortIntents(zoneIntents)) {
630
+ await this.reconcileIntent(intent, zoneIntents);
631
+ }
632
+ } catch (error: unknown) {
633
+ const outcome: IEmailDomainOperationError = {
634
+ code: 'DNS_PROVIDER_ZONE_FAILED',
635
+ message: `DNS zone ${zone.name} reconciliation failed: ${(error as Error).message}`,
636
+ retryable: true,
637
+ };
638
+ errors.set(zone.id, outcome);
639
+ for (const intent of zoneIntents) {
640
+ intent.status = 'failed';
641
+ intent.error = { ...outcome, recordKey: intent.key };
642
+ }
143
643
  }
644
+ }
645
+ return errors;
646
+ }
647
+
648
+ private sortIntents(intents: IEmailDnsRecordIntent[]): IEmailDnsRecordIntent[] {
649
+ const rank = (intent: IEmailDnsRecordIntent): number => {
650
+ if (intent.type === 'A' || intent.type === 'AAAA') return 0;
651
+ if (intent.type === 'TXT' && intent.name.includes('._domainkey.')) return 1;
652
+ if (intent.type === 'TXT') return 2;
653
+ return 3; // MX is exposed only after identity/auth records exist.
144
654
  };
655
+ return [...intents].sort((left, right) => rank(left) - rank(right) || left.key.localeCompare(right.key));
656
+ }
145
657
 
146
- // (a) shared mail hostname → every enabled mail edge
147
- const sharedHostname = this.getSharedMailHostname();
148
- const mailEdges = remoteIngressManager.getMailEdges();
149
- for (const edge of mailEdges) {
150
- pushEdgeIps(sharedHostname, edge);
658
+ private async reconcileIntent(
659
+ intent: IEmailDnsRecordIntent,
660
+ zoneIntents: IEmailDnsRecordIntent[],
661
+ ): Promise<void> {
662
+ const records = await DnsRecordDoc.findByDomainId(intent.domainId);
663
+ const rrset = records.filter((record) => (
664
+ record.name.toLowerCase() === intent.name.toLowerCase() && record.type === intent.type
665
+ ));
666
+ const exact = rrset.filter((record) => this.valuesEqual(intent.type, record.value, intent.value));
667
+ const desiredValues = zoneIntents
668
+ .filter((candidate) => candidate.name.toLowerCase() === intent.name.toLowerCase() && candidate.type === intent.type)
669
+ .map((candidate) => candidate.value);
670
+ const unmanaged = rrset.filter((record) => (
671
+ record.managedBy !== MAIL_DNS_MANAGED_BY && this.isRelevantUnmanagedTxt(intent, record.value)
672
+ ));
673
+ const exactUnmanaged = exact.filter((record) => record.managedBy !== MAIL_DNS_MANAGED_BY);
674
+ const conflictingUnmanaged = unmanaged.filter((record) => (
675
+ !desiredValues.some((desired) => this.valuesEqual(intent.type, record.value, desired))
676
+ ));
677
+ if (conflictingUnmanaged.length > 0 || (intent.type === 'TXT' && exactUnmanaged.length > 1)) {
678
+ intent.status = 'conflict';
679
+ intent.error = {
680
+ code: 'DNS_UNMANAGED_CONFLICT',
681
+ message: `Unmanaged ${intent.type} record conflicts with ${intent.name}`,
682
+ recordKey: intent.key,
683
+ retryable: false,
684
+ };
685
+ return;
686
+ }
687
+ if (exactUnmanaged.length === 1) {
688
+ // Exact operator record satisfies the intent without adoption/deletion.
689
+ // Remove only a dcrouter-owned duplicate of this same intent; other MX
690
+ // 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) {
699
+ const result = await this.dcRouterRef.dnsManager!.deleteRecord(duplicate.id);
700
+ if (!result.success) throw new Error(result.message || `Failed to delete duplicate ${intent.key}`);
701
+ }
702
+ intent.status = 'satisfied';
703
+ return;
151
704
  }
152
705
 
153
- // (b) per-edge FCrDNS hostname → that edge only
154
- for (const edge of remoteIngressManager.getAllEdges()) {
155
- if (!edge.enabled) continue;
156
- pushEdgeIps(edge.mailHostname, edge);
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
+ }
716
+ if (managed) {
717
+ if (!this.valuesEqual(intent.type, managed.value, intent.value) || managed.name !== intent.name) {
718
+ const result = await this.dcRouterRef.dnsManager!.updateRecord({
719
+ id: managed.id,
720
+ name: intent.name,
721
+ value: intent.value,
722
+ ttl: intent.ttl,
723
+ });
724
+ if (!result.success) throw new Error(result.message || `Failed to update ${intent.key}`);
725
+ }
726
+ intent.providerRecordId = managed.providerRecordId;
727
+ intent.status = 'satisfied';
728
+ intent.lastAttemptAt = new Date().toISOString();
729
+ return;
157
730
  }
158
731
 
159
- // (c) mx.<domain> → pinned edges for every pinned email domain
160
- const emailDomainDocs = await EmailDomainDoc.findAll();
161
- for (const doc of emailDomainDocs) {
162
- const edgeFilter = doc.remoteIngress?.edgeFilter;
163
- if (!edgeFilter?.length) continue;
164
- const pinnedEdges = remoteIngressManager.resolveEdgesByFilter(edgeFilter);
165
- if (pinnedEdges.length === 0) {
166
- logger.log('warn', `MailDnsSync: email domain ${doc.domain} pin [${edgeFilter.join(', ')}] matches no enabled edge — mx.${doc.domain} not published`);
167
- continue;
732
+ const result = await this.dcRouterRef.dnsManager!.createRecord({
733
+ domainId: intent.domainId,
734
+ name: intent.name,
735
+ type: intent.type,
736
+ value: intent.value,
737
+ ttl: intent.ttl,
738
+ createdBy: MAIL_DNS_SYNC_CREATED_BY,
739
+ managedBy: MAIL_DNS_MANAGED_BY,
740
+ managedOwnerId: intent.ownerId,
741
+ managedRecordKey: intent.key,
742
+ });
743
+ if (!result.success) throw new Error(result.message || `Failed to create ${intent.key}`);
744
+ intent.status = 'satisfied';
745
+ intent.lastAttemptAt = new Date().toISOString();
746
+ }
747
+
748
+ private valuesEqual(type: TDnsRecordType, left: string, right: string): boolean {
749
+ if (type === 'MX') return normalizeMx(left) === normalizeMx(right);
750
+ if (type === 'A' || type === 'AAAA') return left.trim() === right.trim();
751
+ return left.trim() === right.trim();
752
+ }
753
+
754
+ private isRelevantUnmanagedTxt(intent: IEmailDnsRecordIntent, value: string): boolean {
755
+ if (intent.type !== 'TXT') return true;
756
+ const normalizedIntent = intent.value.trim().toLowerCase();
757
+ const normalizedValue = value.trim().toLowerCase();
758
+ if (normalizedIntent.startsWith('v=spf1')) return normalizedValue.startsWith('v=spf1');
759
+ if (normalizedIntent.startsWith('v=dmarc1')) return normalizedValue.startsWith('v=dmarc1');
760
+ if (normalizedIntent.startsWith('v=dkim1')) return normalizedValue.startsWith('v=dkim1');
761
+ return this.valuesEqual(intent.type, value, intent.value);
762
+ }
763
+
764
+ private async validateAndActivate(
765
+ plan: IPlannedDomain,
766
+ errors: IEmailDomainOperationError[],
767
+ zoneErrors: Map<string, IEmailDomainOperationError>,
768
+ ): Promise<void> {
769
+ const verifiable = plan.intents.filter((intent) => (
770
+ !intent.error && !zoneErrors.has(intent.domainId)
771
+ ));
772
+ 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
+ };
168
785
  }
169
- for (const edge of pinnedEdges) {
170
- pushEdgeIps(`mx.${doc.domain}`, edge);
786
+ };
787
+ const mxByName = new Map<string, IEmailDnsRecordIntent[]>();
788
+ for (const intent of verifiable.filter((candidate) => candidate.type === 'MX')) {
789
+ const group = mxByName.get(intent.name) || [];
790
+ group.push(intent);
791
+ mxByName.set(intent.name, group);
792
+ }
793
+ await Promise.all([
794
+ ...verifiable.filter((intent) => intent.type !== 'MX').map(async (intent) => {
795
+ const result = await this.verifier.verifyRecord(intent)
796
+ .catch((error: unknown) => ({ valid: false, reason: (error as Error).message }));
797
+ markResult(intent, result);
798
+ }),
799
+ ...[...mxByName.values()].map(async (intents) => {
800
+ 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 }));
812
+ for (const intent of intents) markResult(intent, result);
813
+ }),
814
+ ]);
815
+ for (const intent of plan.intents) {
816
+ if (intent.error && !errors.some((error) => error.code === intent.error!.code && error.recordKey === intent.key)) {
817
+ errors.push(intent.error);
171
818
  }
172
819
  }
173
820
 
174
- // Hard hub-hiding guard: a hub IP must never become a mail-facing answer.
175
- const hubIps = new Set(
176
- [this.dcRouterRef.options.publicIp, this.dcRouterRef.detectedPublicIp].filter(Boolean),
177
- );
178
- const guarded = desired.filter((record) => {
179
- if (hubIps.has(record.value)) {
180
- logger.log('error', `MailDnsSync: refusing to publish ${record.type} ${record.name} → ${record.value} — value is the hub public IP`);
181
- return false;
821
+ if (plan.egressIdentitySupported && plan.revision.edgeIdentities.length > 0) {
822
+ const identityResults = await Promise.all(plan.revision.edgeIdentities.map(async (identity) => ({
823
+ identity,
824
+ result: await this.verifier.verifyForwardConfirmedIdentity(identity)
825
+ .catch((error: unknown) => ({ valid: false, reason: (error as Error).message })),
826
+ })));
827
+ for (const { identity, result } of identityResults) {
828
+ if (!result.valid) {
829
+ errors.push({
830
+ code: 'FCRDNS_INVALID',
831
+ message: result.reason || `FCrDNS validation failed for ${identity.edgeId}`,
832
+ retryable: true,
833
+ });
834
+ }
182
835
  }
183
- return true;
184
- });
836
+ }
185
837
 
186
- // Dedupe (several edges may share a tag and a pin may overlap the mail pool)
187
- const seen = new Set<string>();
188
- return guarded.filter((record) => {
189
- const key = `${record.name}|${record.type}|${record.value}`;
190
- if (seen.has(key)) return false;
191
- seen.add(key);
192
- return true;
838
+ const canActivate = plan.egressIdentitySupported
839
+ && plan.revision.edgeIdentities.length > 0
840
+ && (plan.revision.edgeIdentities.length >= 2
841
+ ? plan.revision.mxSlots.length === 2
842
+ : plan.revision.mxSlots.length === 1)
843
+ && plan.selectorCorrectSigningSupported
844
+ && Boolean(this.getActiveDkim(plan.doc))
845
+ && plan.intents.length > 0
846
+ && plan.intents.every((intent) => intent.status === 'valid')
847
+ && 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));
850
+ const previousActive = plan.doc.reconciliation?.activeRevision;
851
+ const topologyChanged = Boolean(previousActive) && JSON.stringify({
852
+ selector: previousActive?.selector,
853
+ edgeIdentities: previousActive?.edgeIdentities,
854
+ mxSlots: previousActive?.mxSlots,
855
+ }) !== JSON.stringify({
856
+ selector: plan.revision.selector,
857
+ edgeIdentities: plan.revision.edgeIdentities,
858
+ mxSlots: plan.revision.mxSlots,
193
859
  });
860
+ const now = new Date().toISOString();
861
+ if (canActivate && plan.doc.pendingDkim) {
862
+ this.promotePendingDkim(plan.doc, now);
863
+ }
864
+ plan.doc.reconciliation = {
865
+ lifecycleStatus: canActivate ? 'active' : hasPermanentError ? 'failed' : 'pending',
866
+ desiredGeneration: plan.doc.reconciliation?.desiredGeneration || 1,
867
+ activeRevision: canActivate
868
+ ? { ...plan.revision, activatedAt: now }
869
+ : plan.doc.reconciliation?.activeRevision,
870
+ pendingRevision: canActivate ? undefined : plan.revision,
871
+ intents: plan.intents,
872
+ errors,
873
+ capability: {
874
+ egressIdentityProof: plan.egressIdentitySupported ? 'supported' : 'unsupported',
875
+ selectorCorrectSigning: plan.selectorCorrectSigningSupported ? 'supported' : 'unsupported',
876
+ },
877
+ lastAttemptAt: now,
878
+ ...(canActivate ? { lastSuccessAt: now } : { retryAt: new Date(Date.now() + MAIL_DNS_RETRY_INTERVAL_MS).toISOString() }),
879
+ };
880
+ plan.doc.dnsStatus = {
881
+ mx: this.statusForIntent(plan.intents, (intent) => intent.type === 'MX'),
882
+ spf: this.statusForIntent(plan.intents, (intent) => intent.type === 'TXT' && intent.value.startsWith('v=spf1')),
883
+ dkim: this.statusForIntent(plan.intents, (intent) => intent.type === 'TXT' && intent.name.includes('._domainkey.')),
884
+ dmarc: this.statusForIntent(plan.intents, (intent) => intent.type === 'TXT' && intent.value.startsWith('v=DMARC1')),
885
+ lastCheckedAt: now,
886
+ };
887
+ plan.doc.updatedAt = now;
888
+ await plan.doc.save();
889
+ if (canActivate && topologyChanged) {
890
+ // The transition SPF intentionally authorizes old + new identities. Run
891
+ // one immediate follow-up to contract it to the newly active revision.
892
+ this.dirty = true;
893
+ }
194
894
  }
195
895
 
196
- private getSharedMailHostname(): string | undefined {
197
- const hostname = this.dcRouterRef.options.emailConfig?.hostname
198
- || this.dcRouterRef.options.tls?.domain;
199
- if (!hostname || hostname === 'localhost') {
200
- return undefined;
896
+ private promotePendingDkim(doc: EmailDomainDoc, now: string): void {
897
+ const pending = doc.pendingDkim;
898
+ if (!pending) return;
899
+ const previous = this.getActiveDkim(doc);
900
+ const overlapDays = Math.max(1, doc.dkim.retirementOverlapDays || 30);
901
+ const retiring = (doc.retiringDkim || []).filter((material) => (
902
+ material.selector !== pending.selector
903
+ && (!material.retireAfter || Date.parse(material.retireAfter) > Date.now())
904
+ ));
905
+ if (previous && previous.selector !== pending.selector) {
906
+ retiring.push({
907
+ ...previous,
908
+ retireAfter: new Date(Date.now() + overlapDays * 86_400_000).toISOString(),
909
+ });
201
910
  }
202
- return hostname;
911
+ doc.activeDkim = { ...pending, validatedAt: now, promotedAt: now };
912
+ doc.pendingDkim = undefined;
913
+ doc.retiringDkim = retiring;
914
+ doc.dkim.selector = pending.selector;
915
+ doc.dkim.keySize = pending.keySize;
916
+ doc.dkim.publicKey = pending.publicKey;
917
+ doc.dkim.lastRotatedAt = now;
203
918
  }
204
919
 
205
- /** Longest-suffix zone match: 'mx.gated.one' → zone 'gated.one'. */
206
- private findZoneForName(name: string, zones: DomainDoc[]): DomainDoc | undefined {
207
- let best: DomainDoc | undefined;
208
- for (const zone of zones) {
209
- const zoneName = zone.name.toLowerCase();
210
- if (name === zoneName || name.endsWith(`.${zoneName}`)) {
211
- if (!best || zoneName.length > best.name.length) {
212
- best = zone;
920
+ private statusForIntent(
921
+ intents: IEmailDnsRecordIntent[],
922
+ predicate: (intent: IEmailDnsRecordIntent) => boolean,
923
+ ): 'valid' | 'missing' | 'invalid' | 'unchecked' {
924
+ const selected = intents.filter(predicate);
925
+ if (selected.length === 0) return 'missing';
926
+ if (selected.every((intent) => intent.status === 'valid')) return 'valid';
927
+ if (selected.some((intent) => intent.status === 'conflict' || intent.status === 'failed')) return 'invalid';
928
+ return 'missing';
929
+ }
930
+
931
+ private async cleanupStaleManagedRecords(zones: DomainDoc[]): Promise<void> {
932
+ const docs = await EmailDomainDoc.findAll();
933
+ const keep = new Set<string>();
934
+ for (const doc of docs) {
935
+ if (doc.reconciliation?.lifecycleStatus === 'deleting') continue;
936
+ for (const intent of doc.reconciliation?.intents || []) keep.add(intent.key);
937
+ if (doc.reconciliation?.lifecycleStatus !== 'active') {
938
+ for (const key of doc.reconciliation?.activeRevision?.recordKeys || []) keep.add(key);
939
+ }
940
+ }
941
+ const managed = (await DnsRecordDoc.findAll()).filter((record) => record.managedBy === MAIL_DNS_MANAGED_BY);
942
+ for (const record of managed) {
943
+ if (record.managedRecordKey && keep.has(record.managedRecordKey)) continue;
944
+ try {
945
+ const result = await this.dcRouterRef.dnsManager!.deleteRecord(record.id);
946
+ if (!result.success) throw new Error(result.message || 'delete failed');
947
+ } catch (error: unknown) {
948
+ const ownerId = record.managedOwnerId?.replace(/^domain:/, '');
949
+ const owner = ownerId ? docs.find((doc) => doc.id === ownerId) : undefined;
950
+ if (owner?.reconciliation) {
951
+ owner.reconciliation.errors.push({
952
+ code: 'DNS_CLEANUP_FAILED',
953
+ message: `Failed to delete ${record.type} ${record.name}: ${(error as Error).message}`,
954
+ recordKey: record.managedRecordKey,
955
+ retryable: true,
956
+ });
957
+ owner.reconciliation.retryAt = new Date(Date.now() + MAIL_DNS_RETRY_INTERVAL_MS).toISOString();
958
+ await owner.save();
213
959
  }
214
960
  }
215
961
  }
216
- return best;
217
962
  }
963
+
964
+ private async finalizeDeletingDomains(): Promise<void> {
965
+ const docs = await EmailDomainDoc.findAll();
966
+ const records = await DnsRecordDoc.findAll();
967
+ for (const doc of docs) {
968
+ if (doc.reconciliation?.lifecycleStatus !== 'deleting') continue;
969
+ const owned = records.some((record) => (
970
+ record.managedBy === MAIL_DNS_MANAGED_BY && record.managedOwnerId === `domain:${doc.id}`
971
+ ));
972
+ if (owned || doc.reconciliation.errors.some((error) => error.code === 'DNS_CLEANUP_FAILED')) continue;
973
+ await doc.delete();
974
+ logger.log('info', `Email domain deletion finalized: ${doc.domain}`);
975
+ }
976
+ }
977
+
978
+ private findZoneForName(name: string, zones: DomainDoc[]): DomainDoc | undefined {
979
+ const normalized = normalizeHostname(name);
980
+ return [...zones]
981
+ .filter((zone) => normalized === normalizeHostname(zone.name) || normalized.endsWith(`.${normalizeHostname(zone.name)}`))
982
+ .sort((left, right) => right.name.length - left.name.length)[0];
983
+ }
984
+
218
985
  }