@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
@@ -4,22 +4,40 @@ import { logger } from '../logger.js';
4
4
  import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
5
5
  import { DomainDoc } from '../db/documents/classes.domain.doc.js';
6
6
  import { DnsRecordDoc } from '../db/documents/classes.dns-record.doc.js';
7
- import type { DnsManager } from '../dns/manager.dns.js';
8
- import type { IEmailDomain, IEmailDnsRecord, IEmailDomainRemoteIngress, TDnsRecordStatus } from '../../ts_interfaces/data/email-domain.js';
9
- import type { IRemoteIngress } from '../../ts_interfaces/data/remoteingress.js';
10
- import { buildEmailDnsRecords } from './email-dns-records.js';
7
+ import type {
8
+ IEmailDomain,
9
+ IEmailDnsRecord,
10
+ IEmailDomainRemoteIngress,
11
+ IEmailDkimMaterial,
12
+ IEmailDomainOperationError,
13
+ TEmailDomainLifecycleStatus,
14
+ } from '../../ts_interfaces/data/email-domain.js';
15
+
16
+ export interface IEmailDomainManagerActionResult {
17
+ success: boolean;
18
+ message?: string;
19
+ lifecycleStatus?: TEmailDomainLifecycleStatus;
20
+ code?: string;
21
+ errors?: IEmailDomainOperationError[];
22
+ retryAt?: string;
23
+ capability?: 'supported' | 'unsupported';
24
+ domain?: IEmailDomain;
25
+ records?: IEmailDnsRecord[];
26
+ provisioned?: number;
27
+ }
11
28
 
12
29
  /**
13
- * EmailDomainManager — orchestrates email domain setup.
14
- *
15
- * Wires smartmta's DKIMCreator (key generation) with dcrouter's DnsManager
16
- * (record creation for dcrouter-hosted and provider-managed zones) to provide
17
- * a single entry point for setting up an email domain from A to Z.
30
+ * Persists managed email domains and projects them into SmartMTA. DNS mutation
31
+ * is delegated exclusively to MailDnsSync.
18
32
  */
19
33
  export class EmailDomainManager {
20
34
  private dcRouter: any; // DcRouter — avoids circular import
21
35
  private baseEmailDomains: IEmailDomainConfig[] = [];
22
- private dnsClient?: plugins.smartdns.dnsClientMod.Smartdns;
36
+ private mutationChain: Promise<unknown> = Promise.resolve();
37
+ /** Explicit upstream capability marker; absence is deliberately fail-closed. */
38
+ private get selectorCorrectSigningCapability(): boolean {
39
+ return (plugins.smartmta as any).smartMtaCapabilities?.selectorCorrectDkimSigning === true;
40
+ }
23
41
 
24
42
  constructor(dcRouterRef: any) {
25
43
  this.dcRouter = dcRouterRef;
@@ -31,34 +49,33 @@ export class EmailDomainManager {
31
49
  .map((domainConfig) => JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
32
50
  }
33
51
 
34
- private get dnsManager(): DnsManager | undefined {
35
- return this.dcRouter.dnsManager;
36
- }
37
-
38
52
  private get dkimCreator(): any | undefined {
39
53
  return this.dcRouter.emailServer?.dkimCreator;
40
54
  }
41
55
 
42
- private get emailHostname(): string {
43
- return this.dcRouter.options?.emailConfig?.hostname || this.dcRouter.options?.tls?.domain || 'localhost';
44
- }
45
-
46
56
  public async start(): Promise<void> {
57
+ // Inbound projection is safe before the SMTP server exists. DNS scheduling
58
+ // starts later, after DNS, RemoteIngress, tunnel, and DKIM dependencies.
47
59
  await this.syncManagedDomainsToRuntime();
48
60
  }
49
61
 
50
62
  public async stop(): Promise<void> {
51
- this.dnsClient?.destroy();
52
- this.dnsClient = undefined;
63
+ await this.mutationChain.catch(() => undefined);
64
+ }
65
+
66
+ public supportsSelectorCorrectSigning(): boolean {
67
+ return this.selectorCorrectSigningCapability;
53
68
  }
54
69
 
55
- // ---------------------------------------------------------------------------
56
- // CRUD
57
- // ---------------------------------------------------------------------------
70
+ private async runMutationExclusive<T>(action: () => Promise<T>): Promise<T> {
71
+ const result = this.mutationChain.catch(() => undefined).then(action);
72
+ this.mutationChain = result.then(() => undefined, () => undefined);
73
+ return await result;
74
+ }
58
75
 
59
76
  public async getAll(): Promise<IEmailDomain[]> {
60
77
  const docs = await EmailDomainDoc.findAll();
61
- return docs.map((d) => this.docToInterface(d));
78
+ return docs.map((doc) => this.docToInterface(doc));
62
79
  }
63
80
 
64
81
  public async getById(id: string): Promise<IEmailDomain | null> {
@@ -76,19 +93,12 @@ export class EmailDomainManager {
76
93
  const existing = await this.getByDomain(normalizedDomain);
77
94
  if (existing) return existing;
78
95
  if (this.isDomainAlreadyConfigured(normalizedDomain)) return null;
79
-
80
96
  const linkedDomain = await this.findLinkedDnsDomain(normalizedDomain);
81
- if (!linkedDomain) {
82
- throw new Error(`DNS domain not found for email domain: ${normalizedDomain}`);
83
- }
84
-
97
+ if (!linkedDomain) throw new Error(`DNS domain not found for email domain: ${normalizedDomain}`);
85
98
  const subdomain = normalizedDomain === linkedDomain.name
86
99
  ? undefined
87
100
  : normalizedDomain.slice(0, -(linkedDomain.name.length + 1));
88
- return await this.createEmailDomain({
89
- linkedDomainId: linkedDomain.id,
90
- subdomain,
91
- });
101
+ return await this.createEmailDomain({ linkedDomainId: linkedDomain.id, subdomain });
92
102
  }
93
103
 
94
104
  public async createEmailDomain(opts: {
@@ -100,71 +110,100 @@ export class EmailDomainManager {
100
110
  rotationIntervalDays?: number;
101
111
  remoteIngress?: IEmailDomainRemoteIngress;
102
112
  }): Promise<IEmailDomain> {
103
- // Resolve the linked DNS domain
104
- const domainDoc = await DomainDoc.findById(opts.linkedDomainId);
105
- if (!domainDoc) {
106
- throw new Error(`DNS domain not found: ${opts.linkedDomainId}`);
107
- }
108
- const baseDomain = domainDoc.name;
109
- const subdomain = opts.subdomain?.trim() || undefined;
110
- const domainName = subdomain ? `${subdomain}.${baseDomain}` : baseDomain;
111
-
112
- // Check for duplicates
113
- if (this.isDomainAlreadyConfigured(domainName)) {
114
- throw new Error(`Email domain already configured for ${domainName}`);
115
- }
116
- const existing = await EmailDomainDoc.findByDomain(domainName);
117
- if (existing) {
118
- throw new Error(`Email domain already exists for ${domainName}`);
119
- }
113
+ const created = await this.runMutationExclusive(async () => {
114
+ const domainDoc = await DomainDoc.findById(opts.linkedDomainId);
115
+ if (!domainDoc) throw new Error(`DNS domain not found: ${opts.linkedDomainId}`);
116
+ const subdomain = opts.subdomain?.trim().toLowerCase() || undefined;
117
+ const domainName = (subdomain ? `${subdomain}.${domainDoc.name}` : domainDoc.name).toLowerCase();
118
+ if (this.isDomainAlreadyConfigured(domainName)) {
119
+ throw new Error(`Email domain already configured for ${domainName}`);
120
+ }
121
+ if (await EmailDomainDoc.findByDomain(domainName)) {
122
+ throw new Error(`Email domain already exists for ${domainName}`);
123
+ }
120
124
 
121
- const selector = opts.dkimSelector || 'default';
122
- const keySize = opts.dkimKeySize || 2048;
123
- const now = new Date().toISOString();
125
+ const selector = opts.dkimSelector?.trim() || 'default';
126
+ if (selector !== 'default' && !this.selectorCorrectSigningCapability) {
127
+ throw this.capabilityError(
128
+ 'SMARTMTA_SELECTOR_SIGNING_UNSUPPORTED',
129
+ 'Custom DKIM selectors are disabled until the selector-correct SmartMTA release is consumed',
130
+ );
131
+ }
132
+ if (opts.rotateKeys && !this.selectorCorrectSigningCapability) {
133
+ throw this.capabilityError(
134
+ 'SMARTMTA_DKIM_ROTATION_UNSUPPORTED',
135
+ 'Automatic DKIM rotation is disabled until the selector-correct SmartMTA release is consumed',
136
+ );
137
+ }
138
+ if (!this.dkimCreator) {
139
+ throw new Error('DKIM key creator is not ready; email domain was not persisted');
140
+ }
141
+ const keySize = opts.dkimKeySize || 2048;
142
+ await this.dkimCreator.handleDKIMKeysForSelector(domainName, selector, keySize);
143
+ const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domainName, selector);
144
+ const publicKey = this.extractDkimPublicKey(dnsRecord?.value);
145
+ if (!publicKey) {
146
+ throw new Error(`DKIM key generation for ${domainName} returned no usable public key; email domain was not persisted`);
147
+ }
124
148
 
125
- // Generate DKIM keys
126
- let publicKey: string | undefined;
127
- if (this.dkimCreator) {
149
+ const now = new Date().toISOString();
150
+ const activeDkim: IEmailDkimMaterial = {
151
+ selector,
152
+ keySize,
153
+ publicKey,
154
+ createdAt: now,
155
+ };
156
+ const doc = new EmailDomainDoc();
157
+ doc.id = plugins.smartunique.shortId();
158
+ doc.domain = domainName;
159
+ doc.linkedDomainId = opts.linkedDomainId;
160
+ doc.subdomain = subdomain;
161
+ doc.dkim = {
162
+ selector,
163
+ keySize,
164
+ publicKey,
165
+ rotateKeys: opts.rotateKeys ?? false,
166
+ rotationIntervalDays: opts.rotationIntervalDays ?? 90,
167
+ retirementOverlapDays: 30,
168
+ };
169
+ doc.activeDkim = activeDkim;
170
+ doc.retiringDkim = [];
171
+ doc.remoteIngress = this.normalizeRemoteIngressPin(opts.remoteIngress, domainName);
172
+ doc.dnsStatus = { mx: 'unchecked', spf: 'unchecked', dkim: 'unchecked', dmarc: 'unchecked' };
173
+ doc.reconciliation = {
174
+ lifecycleStatus: 'pending',
175
+ desiredGeneration: 1,
176
+ intents: [],
177
+ errors: [{
178
+ code: 'RECONCILIATION_PENDING',
179
+ message: 'Initial DNS reconciliation has not completed',
180
+ retryable: true,
181
+ }],
182
+ capability: {
183
+ egressIdentityProof: 'unsupported',
184
+ selectorCorrectSigning: selector === 'default' || this.selectorCorrectSigningCapability
185
+ ? 'supported'
186
+ : 'unsupported',
187
+ },
188
+ };
189
+ doc.createdAt = now;
190
+ doc.updatedAt = now;
128
191
  try {
129
- await this.dkimCreator.handleDKIMKeysForSelector(domainName, selector, keySize);
130
- const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domainName, selector);
131
- // Extract public key from the DNS record value
132
- const match = dnsRecord?.value?.match(/p=([A-Za-z0-9+/=]+)/);
133
- publicKey = match ? match[1] : undefined;
134
- logger.log('info', `DKIM keys generated for ${domainName} (selector: ${selector})`);
135
- } catch (err: unknown) {
136
- logger.log('warn', `DKIM key generation failed for ${domainName}: ${(err as Error).message}`);
192
+ await doc.save();
193
+ } catch (error: unknown) {
194
+ if ((error as any)?.code === 11000 || (error as any)?.codeName === 'DuplicateKey') {
195
+ throw new Error(`Email domain already exists for ${domainName}`);
196
+ }
197
+ throw error;
137
198
  }
138
- }
139
-
140
- // Create the document
141
- const doc = new EmailDomainDoc();
142
- doc.id = plugins.smartunique.shortId();
143
- doc.domain = domainName.toLowerCase();
144
- doc.linkedDomainId = opts.linkedDomainId;
145
- doc.subdomain = subdomain;
146
- doc.dkim = {
147
- selector,
148
- keySize,
149
- publicKey,
150
- rotateKeys: opts.rotateKeys ?? false,
151
- rotationIntervalDays: opts.rotationIntervalDays ?? 90,
152
- };
153
- doc.dnsStatus = {
154
- mx: 'unchecked',
155
- spf: 'unchecked',
156
- dkim: 'unchecked',
157
- dmarc: 'unchecked',
158
- };
159
- doc.remoteIngress = this.normalizeRemoteIngressPin(opts.remoteIngress, domainName);
160
- doc.createdAt = now;
161
- doc.updatedAt = now;
162
- await doc.save();
163
- await this.syncManagedDomainsToRuntime();
164
- this.dcRouter.mailDnsSync?.requestSync(`email domain created: ${domainName}`);
165
-
166
- logger.log('info', `Email domain created: ${domainName}`);
167
- return this.docToInterface(doc);
199
+ await this.syncManagedDomainsToRuntime();
200
+ return { id: doc.id, domainName };
201
+ });
202
+ await this.dcRouter.mailDnsSync?.sync(`email domain created: ${created.domainName}`);
203
+ const current = await EmailDomainDoc.findById(created.id);
204
+ if (!current) throw new Error(`Email domain disappeared during reconciliation: ${created.domainName}`);
205
+ logger.log('info', `Email domain created in ${current.reconciliation?.lifecycleStatus || 'pending'} state: ${created.domainName}`);
206
+ return this.docToInterface(current);
168
207
  }
169
208
 
170
209
  public async updateEmailDomain(
@@ -173,292 +212,307 @@ export class EmailDomainManager {
173
212
  rotateKeys?: boolean;
174
213
  rotationIntervalDays?: number;
175
214
  rateLimits?: IEmailDomain['rateLimits'];
176
- /** null clears the pin. */
177
215
  remoteIngress?: IEmailDomainRemoteIngress | null;
178
216
  },
179
- ): Promise<void> {
180
- const doc = await EmailDomainDoc.findById(id);
181
- if (!doc) throw new Error(`Email domain not found: ${id}`);
182
-
183
- if (changes.rotateKeys !== undefined) doc.dkim.rotateKeys = changes.rotateKeys;
184
- if (changes.rotationIntervalDays !== undefined) doc.dkim.rotationIntervalDays = changes.rotationIntervalDays;
185
- if (changes.rateLimits !== undefined) doc.rateLimits = changes.rateLimits;
186
- if (changes.remoteIngress !== undefined) {
187
- doc.remoteIngress = changes.remoteIngress === null
188
- ? undefined
189
- : this.normalizeRemoteIngressPin(changes.remoteIngress, doc.domain);
190
- }
191
- doc.updatedAt = new Date().toISOString();
192
- await doc.save();
193
- await this.syncManagedDomainsToRuntime();
194
- this.dcRouter.mailDnsSync?.requestSync(`email domain updated: ${doc.domain}`);
217
+ ): Promise<IEmailDomainManagerActionResult> {
218
+ const mutation = await this.runMutationExclusive(async () => {
219
+ const doc = await EmailDomainDoc.findById(id);
220
+ if (!doc) throw new Error(`Email domain not found: ${id}`);
221
+ if (changes.rotateKeys === true && !this.selectorCorrectSigningCapability) {
222
+ return {
223
+ kind: 'result' as const,
224
+ result: this.unsupportedResult(
225
+ doc,
226
+ 'SMARTMTA_DKIM_ROTATION_UNSUPPORTED',
227
+ 'Automatic DKIM rotation is disabled until the selector-correct SmartMTA release is consumed',
228
+ ),
229
+ };
230
+ }
231
+ if (changes.rotateKeys !== undefined) doc.dkim.rotateKeys = changes.rotateKeys;
232
+ if (changes.rotationIntervalDays !== undefined) doc.dkim.rotationIntervalDays = changes.rotationIntervalDays;
233
+ if (changes.rateLimits !== undefined) doc.rateLimits = changes.rateLimits;
234
+ if (changes.remoteIngress !== undefined) {
235
+ doc.remoteIngress = changes.remoteIngress === null
236
+ ? undefined
237
+ : this.normalizeRemoteIngressPin(changes.remoteIngress, doc.domain);
238
+ }
239
+ doc.reconciliation = this.invalidateReconciliation(doc, 'EMAIL_DOMAIN_CONFIGURATION_CHANGED');
240
+ doc.updatedAt = new Date().toISOString();
241
+ await doc.save();
242
+ await this.syncManagedDomainsToRuntime();
243
+ return { kind: 'sync' as const, domainName: doc.domain };
244
+ });
245
+ if (mutation.kind === 'result') return mutation.result;
246
+ await this.dcRouter.mailDnsSync?.sync(`email domain updated: ${mutation.domainName}`);
247
+ const current = await EmailDomainDoc.findById(id);
248
+ if (!current) throw new Error(`Email domain disappeared during reconciliation: ${id}`);
249
+ return this.resultForDoc(current, !this.hasDnsMutationFailure(current));
195
250
  }
196
251
 
197
- /**
198
- * Normalize a per-domain edge pin. Pins may reference edges that are not
199
- * registered yet — that only warns, so pins can precede edge registration.
200
- */
201
- private normalizeRemoteIngressPin(
202
- pin: IEmailDomainRemoteIngress | undefined,
203
- domainName: string,
204
- ): IEmailDomainRemoteIngress | undefined {
205
- const edgeFilter = [...new Set((pin?.edgeFilter || []).map((entry) => `${entry}`.trim()).filter(Boolean))];
206
- if (edgeFilter.length === 0) {
207
- return undefined;
208
- }
209
- const manager = this.dcRouter.remoteIngressManager;
210
- if (manager && manager.resolveEdgesByFilter(edgeFilter).length === 0) {
211
- logger.log('warn', `Email domain ${domainName}: edge pin [${edgeFilter.join(', ')}] matches no registered enabled edge yet`);
252
+ public async deleteEmailDomain(id: string): Promise<IEmailDomainManagerActionResult> {
253
+ const mutation = await this.runMutationExclusive(async () => {
254
+ const doc = await EmailDomainDoc.findById(id);
255
+ if (!doc) throw new Error(`Email domain not found: ${id}`);
256
+ const bindings = await this.dcRouter.workAppMailManager?.listMailAddressBindings({ domain: doc.domain }) || [];
257
+ if (bindings.length > 0) {
258
+ return {
259
+ kind: 'result' as const,
260
+ result: {
261
+ success: false,
262
+ lifecycleStatus: doc.reconciliation?.lifecycleStatus || 'pending',
263
+ code: 'EMAIL_DOMAIN_HAS_BINDINGS',
264
+ message: `Email domain ${doc.domain} still has ${bindings.length} WorkApp mail binding(s)`,
265
+ errors: [{
266
+ code: 'EMAIL_DOMAIN_HAS_BINDINGS',
267
+ message: 'Delete all WorkApp mail bindings before deleting the email domain',
268
+ retryable: false,
269
+ }],
270
+ } satisfies IEmailDomainManagerActionResult,
271
+ };
272
+ }
273
+ const now = new Date().toISOString();
274
+ doc.reconciliation = {
275
+ ...(doc.reconciliation || { desiredGeneration: 1, intents: [], errors: [] }),
276
+ lifecycleStatus: 'deleting',
277
+ intents: [],
278
+ errors: [],
279
+ deletingAt: now,
280
+ retryAt: new Date(Date.now() + 300_000).toISOString(),
281
+ };
282
+ doc.updatedAt = now;
283
+ await doc.save();
284
+ await this.syncManagedDomainsToRuntime();
285
+ return { kind: 'sync' as const, domainName: doc.domain };
286
+ });
287
+ if (mutation.kind === 'result') return mutation.result;
288
+ await this.dcRouter.mailDnsSync?.sync(`email domain deleting: ${mutation.domainName}`);
289
+ const remaining = await EmailDomainDoc.findById(id);
290
+ if (!remaining) {
291
+ return { success: true, lifecycleStatus: 'deleting', message: `Email domain ${mutation.domainName} deleted` };
212
292
  }
213
- return { edgeFilter };
293
+ return this.resultForDoc(remaining, remaining.reconciliation?.errors.length === 0);
214
294
  }
215
295
 
216
- /** Enabled edges pinned for this domain (empty when unpinned). */
217
- private resolvePinnedEdges(doc: EmailDomainDoc): IRemoteIngress[] {
218
- const edgeFilter = doc.remoteIngress?.edgeFilter;
219
- if (!edgeFilter?.length) {
220
- return [];
221
- }
222
- return this.dcRouter.remoteIngressManager?.resolveEdgesByFilter(edgeFilter) || [];
296
+ public async getRequiredDnsRecords(id: string): Promise<IEmailDnsRecord[]> {
297
+ const doc = await EmailDomainDoc.findById(id);
298
+ if (!doc) throw new Error(`Email domain not found: ${id}`);
299
+ return (doc.reconciliation?.intents || []).map((intent) => ({
300
+ type: intent.type,
301
+ name: intent.name,
302
+ value: intent.value,
303
+ status: intent.status === 'valid'
304
+ ? 'valid'
305
+ : intent.status === 'conflict' || intent.status === 'failed'
306
+ ? 'invalid'
307
+ : 'missing',
308
+ }));
223
309
  }
224
310
 
225
- public async deleteEmailDomain(id: string): Promise<void> {
311
+ public async provisionDnsRecords(id: string): Promise<IEmailDomainManagerActionResult> {
312
+ const before = new Set(
313
+ (await DnsRecordDoc.findAll()).filter((record) => record.managedBy === 'mail-dns-reconciler').map((record) => record.id),
314
+ );
315
+ await this.dcRouter.mailDnsSync?.sync(`manual provision: ${id}`);
226
316
  const doc = await EmailDomainDoc.findById(id);
227
317
  if (!doc) throw new Error(`Email domain not found: ${id}`);
228
- await doc.delete();
229
- await this.syncManagedDomainsToRuntime();
230
- this.dcRouter.mailDnsSync?.requestSync(`email domain deleted: ${doc.domain}`);
231
- logger.log('info', `Email domain deleted: ${doc.domain}`);
318
+ const provisioned = (await DnsRecordDoc.findAll())
319
+ .filter((record) => record.managedBy === 'mail-dns-reconciler' && !before.has(record.id)).length;
320
+ return { ...this.resultForDoc(doc, !this.hasDnsMutationFailure(doc)), provisioned };
232
321
  }
233
322
 
234
- // ---------------------------------------------------------------------------
235
- // DNS record computation
236
- // ---------------------------------------------------------------------------
237
-
238
- /**
239
- * Compute the 4 required DNS records for an email domain.
240
- */
241
- public async getRequiredDnsRecords(id: string): Promise<IEmailDnsRecord[]> {
323
+ public async validateDns(id: string): Promise<IEmailDomainManagerActionResult> {
324
+ await this.dcRouter.mailDnsSync?.sync(`manual validation: ${id}`);
242
325
  const doc = await EmailDomainDoc.findById(id);
243
326
  if (!doc) throw new Error(`Email domain not found: ${id}`);
327
+ const records = await this.getRequiredDnsRecords(id);
328
+ return {
329
+ ...this.resultForDoc(doc, doc.reconciliation?.lifecycleStatus === 'active'),
330
+ domain: this.docToInterface(doc),
331
+ records,
332
+ };
333
+ }
244
334
 
245
- const domain = doc.domain;
246
- const selector = doc.dkim.selector;
247
- // Pinned domains get their own MX host whose A records are the pinned edges'
248
- // public IPs; unpinned domains use the shared mail hostname.
249
- const pinnedEdges = this.resolvePinnedEdges(doc);
250
- const hostname = pinnedEdges.length > 0 ? `mx.${domain}` : this.emailHostname;
251
- let dkimValue = `v=DKIM1; h=sha256; k=rsa; p=${doc.dkim.publicKey || ''}`;
252
-
253
- if (this.dkimCreator) {
254
- try {
255
- const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domain, selector);
256
- dkimValue = dnsRecord.value;
257
- } catch (err: unknown) {
258
- logger.log('warn', `Failed to load DKIM DNS record for ${domain}: ${(err as Error).message}`);
259
- }
335
+ public async getOutboundReadiness(domainName: string): Promise<{
336
+ ready: boolean;
337
+ reason?: string;
338
+ edgeIds: string[];
339
+ selector?: string;
340
+ }> {
341
+ const doc = await EmailDomainDoc.findByDomain(domainName.toLowerCase());
342
+ if (!doc) return { ready: true, edgeIds: [] }; // unmanaged/static domain policy
343
+ const activeDkim = this.getActiveDkim(doc);
344
+ if (!activeDkim) return { ready: false, reason: 'no usable active DKIM key', edgeIds: [] };
345
+ if (activeDkim.selector !== 'default' && !this.selectorCorrectSigningCapability) {
346
+ return { ready: false, reason: 'SmartMTA selector-correct signing capability is unavailable', edgeIds: [] };
260
347
  }
261
-
262
- const records = buildEmailDnsRecords({
263
- domain,
264
- hostname,
265
- selector,
266
- dkimValue,
267
- statuses: doc.dnsStatus,
268
- });
269
-
270
- for (const edge of pinnedEdges) {
271
- if (edge.publicIp) {
272
- records.push({ type: 'A', name: hostname, value: edge.publicIp, status: doc.dnsStatus.mx });
273
- }
274
- if (edge.publicIpV6) {
275
- records.push({ type: 'AAAA', name: hostname, value: edge.publicIpV6, status: doc.dnsStatus.mx });
276
- }
348
+ if (!doc.reconciliation?.activeRevision) {
349
+ return { ready: false, reason: 'no validated active DNS revision', edgeIds: [] };
277
350
  }
278
-
279
- return records;
351
+ const live = await this.dcRouter.mailEdgeEligibility.resolveActiveForDomain(doc);
352
+ if (live.edges.length === 0) {
353
+ return { ready: false, reason: live.reason || 'no validated active egress edge is live', edgeIds: [] };
354
+ }
355
+ return {
356
+ ready: true,
357
+ edgeIds: live.edges.map((candidate: any) => candidate.edge.id),
358
+ selector: activeDkim.selector,
359
+ };
280
360
  }
281
361
 
282
- // ---------------------------------------------------------------------------
283
- // DNS provisioning
284
- // ---------------------------------------------------------------------------
285
-
286
- /**
287
- * Auto-create missing DNS records via the linked domain's DNS path.
288
- */
289
- public async provisionDnsRecords(id: string): Promise<number> {
290
- const doc = await EmailDomainDoc.findById(id);
291
- if (!doc) throw new Error(`Email domain not found: ${id}`);
292
- if (!this.dnsManager) throw new Error('DnsManager not available');
293
-
294
- const requiredRecords = await this.getRequiredDnsRecords(id);
295
- const domainId = doc.linkedDomainId;
296
-
297
- // Get existing DNS records for the linked domain
298
- const existingRecords = await DnsRecordDoc.findByDomainId(domainId);
299
- let provisioned = 0;
300
-
301
- for (const required of requiredRecords) {
302
- // Check if a matching record already exists
303
- const exists = existingRecords.some((r) => this.recordMatchesRequired(r, required));
304
-
305
- if (!exists) {
306
- try {
307
- await this.dnsManager.createRecord({
308
- domainId,
309
- name: required.name,
310
- type: required.type as any,
311
- value: required.value,
312
- ttl: 3600,
313
- createdBy: 'email-domain-manager',
314
- });
315
- provisioned++;
316
- logger.log('info', `Provisioned ${required.type} record for ${required.name}`);
317
- } catch (err: unknown) {
318
- logger.log('warn', `Failed to provision ${required.type} for ${required.name}: ${(err as Error).message}`);
319
- }
362
+ public async requestDkimRotation(id: string): Promise<IEmailDomainManagerActionResult> {
363
+ const mutation = await this.runMutationExclusive(async () => {
364
+ const doc = await EmailDomainDoc.findById(id);
365
+ if (!doc) throw new Error(`Email domain not found: ${id}`);
366
+ if (!this.selectorCorrectSigningCapability) {
367
+ return {
368
+ kind: 'result' as const,
369
+ result: this.unsupportedResult(
370
+ doc,
371
+ 'SMARTMTA_DKIM_ROTATION_UNSUPPORTED',
372
+ 'DKIM rotation remains pending until the selector-correct SmartMTA release is consumed',
373
+ ),
374
+ };
320
375
  }
321
- }
322
-
323
- // Re-validate after provisioning
324
- await this.validateDns(id);
325
-
326
- return provisioned;
376
+ if (doc.pendingDkim) {
377
+ return {
378
+ kind: 'result' as const,
379
+ result: {
380
+ ...this.resultForDoc(doc, true),
381
+ message: `Selector ${doc.pendingDkim.selector} is already staged`,
382
+ },
383
+ };
384
+ }
385
+ await this.stageDkimRotation(doc);
386
+ return { kind: 'sync' as const, domainName: doc.domain };
387
+ });
388
+ if (mutation.kind === 'result') return mutation.result;
389
+ await this.dcRouter.mailDnsSync?.sync(`DKIM rotation staged: ${mutation.domainName}`);
390
+ const current = await EmailDomainDoc.findById(id);
391
+ if (!current) throw new Error(`Email domain disappeared during reconciliation: ${id}`);
392
+ return this.resultForDoc(current, !this.hasDnsMutationFailure(current));
327
393
  }
328
394
 
329
- // ---------------------------------------------------------------------------
330
- // DNS validation
331
- // ---------------------------------------------------------------------------
332
-
333
- /**
334
- * Validate DNS records via live lookups.
335
- */
336
- public async validateDns(id: string): Promise<IEmailDnsRecord[]> {
337
- const doc = await EmailDomainDoc.findById(id);
338
- if (!doc) throw new Error(`Email domain not found: ${id}`);
339
-
340
- const domain = doc.domain;
341
- const selector = doc.dkim.selector;
342
- const dnsClient = this.getDnsClient();
343
-
344
- // MX check
345
- const requiredRecords = await this.getRequiredDnsRecords(id);
346
-
347
- const mxRecord = requiredRecords.find((record) => record.type === 'MX');
348
- const spfRecord = requiredRecords.find((record) => record.name === domain && record.value.startsWith('v=spf1'));
349
- const dkimRecord = requiredRecords.find((record) => record.name === `${selector}._domainkey.${domain}`);
350
- const dmarcRecord = requiredRecords.find((record) => record.name === `_dmarc.${domain}`);
351
-
352
- doc.dnsStatus.mx = await this.checkMx(dnsClient, domain, mxRecord?.value);
353
-
354
- // Pinned domains: the MX host's A records must cover the pinned edges' IPs.
355
- const requiredARecords = requiredRecords.filter((record) => record.type === 'A');
356
- if (doc.dnsStatus.mx === 'valid' && requiredARecords.length > 0) {
357
- const mxHostname = mxRecord?.value?.split(/\s+/)[1] || `mx.${domain}`;
358
- const aStatus = await this.checkARecords(dnsClient, mxHostname, requiredARecords.map((record) => record.value));
359
- if (aStatus !== 'valid') {
360
- doc.dnsStatus.mx = aStatus;
395
+ /** Called by the reconciler before planning so scheduled external rotation is durable first. */
396
+ public async stageDkimRotationIfDue(id: string): Promise<EmailDomainDoc | undefined> {
397
+ return await this.runMutationExclusive(async () => {
398
+ const doc = await EmailDomainDoc.findById(id);
399
+ if (!doc || !doc.dkim.rotateKeys || doc.pendingDkim || !this.selectorCorrectSigningCapability) {
400
+ return doc || undefined;
361
401
  }
362
- }
363
-
364
- // SPF check
365
- doc.dnsStatus.spf = await this.checkTxtRecord(dnsClient, domain, spfRecord?.value);
366
-
367
- // DKIM check
368
- doc.dnsStatus.dkim = await this.checkTxtRecord(dnsClient, `${selector}._domainkey.${domain}`, dkimRecord?.value);
369
-
370
- // DMARC check
371
- doc.dnsStatus.dmarc = await this.checkTxtRecord(dnsClient, `_dmarc.${domain}`, dmarcRecord?.value);
402
+ const active = this.getActiveDkim(doc);
403
+ const reference = Date.parse(active?.promotedAt || doc.dkim.lastRotatedAt || active?.createdAt || doc.createdAt);
404
+ const intervalMs = Math.max(1, doc.dkim.rotationIntervalDays || 90) * 86_400_000;
405
+ if (Number.isFinite(reference) && Date.now() - reference < intervalMs) return doc;
406
+ try {
407
+ await this.stageDkimRotation(doc);
408
+ } catch (error: unknown) {
409
+ doc.reconciliation = this.invalidateReconciliation(doc, 'DKIM_ROTATION_STAGE_FAILED');
410
+ doc.reconciliation.errors = [{
411
+ code: 'DKIM_ROTATION_STAGE_FAILED',
412
+ message: `Failed to stage DKIM rotation: ${(error as Error).message}`,
413
+ retryable: true,
414
+ }];
415
+ doc.updatedAt = new Date().toISOString();
416
+ await doc.save();
417
+ }
418
+ return doc;
419
+ });
420
+ }
372
421
 
373
- doc.dnsStatus.lastCheckedAt = new Date().toISOString();
422
+ private async stageDkimRotation(doc: EmailDomainDoc): Promise<void> {
423
+ if (!this.dkimCreator) throw new Error('DKIM key creator is not ready');
424
+ const selector = `s${new Date().toISOString().slice(0, 10).replace(/-/g, '')}-${plugins.smartunique.shortId()}`;
425
+ const keySize = doc.dkim.keySize || 2048;
426
+ await this.dkimCreator.handleDKIMKeysForSelector(doc.domain, selector, keySize);
427
+ const record = await this.dkimCreator.getDNSRecordForDomain(doc.domain, selector);
428
+ const publicKey = this.extractDkimPublicKey(record?.value);
429
+ if (!publicKey) throw new Error(`DKIM rotation generated no public key for ${selector}`);
430
+ doc.pendingDkim = { selector, keySize, publicKey, createdAt: new Date().toISOString() };
431
+ doc.reconciliation = this.invalidateReconciliation(doc, 'DKIM_ROTATION_STAGED');
374
432
  doc.updatedAt = new Date().toISOString();
375
433
  await doc.save();
376
-
377
- return this.getRequiredDnsRecords(id);
378
434
  }
379
435
 
380
- private recordMatchesRequired(record: DnsRecordDoc, required: IEmailDnsRecord): boolean {
381
- if (record.type !== required.type || record.name.toLowerCase() !== required.name.toLowerCase()) {
382
- return false;
436
+ private normalizeRemoteIngressPin(
437
+ pin: IEmailDomainRemoteIngress | undefined,
438
+ domainName: string,
439
+ ): IEmailDomainRemoteIngress | undefined {
440
+ const edgeFilter = [...new Set((pin?.edgeFilter || []).map((entry) => `${entry}`.trim()).filter(Boolean))];
441
+ if (edgeFilter.length === 0) return undefined;
442
+ const manager = this.dcRouter.remoteIngressManager;
443
+ if (manager && manager.resolveEdgesByFilter(edgeFilter).length === 0) {
444
+ logger.log('warn', `Email domain ${domainName}: pin [${edgeFilter.join(', ')}] matches no enabled edge yet`);
383
445
  }
384
- return record.value.trim() === required.value.trim();
446
+ return { edgeFilter };
385
447
  }
386
448
 
387
- /** Lazy shared smartdns client; the Rust bridge spawns on first non-system lookup. */
388
- private getDnsClient(): plugins.smartdns.dnsClientMod.Smartdns {
389
- if (!this.dnsClient) {
390
- this.dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({});
391
- }
392
- return this.dnsClient;
449
+ private extractDkimPublicKey(value: unknown): string | undefined {
450
+ if (typeof value !== 'string') return undefined;
451
+ return value.match(/(?:^|;)\s*p=([A-Za-z0-9+/=]+)(?:;|$)/)?.[1];
393
452
  }
394
453
 
395
- /** 'pref exchange' normalized for comparison: numeric pref, lowercased exchange, no trailing dot. */
396
- private normalizeMxValue(value: string): string {
397
- const [pref, exchange = ''] = value.trim().split(/\s+/);
398
- return `${Number(pref)} ${exchange.toLowerCase().replace(/\.$/, '')}`;
454
+ private getActiveDkim(doc: EmailDomainDoc): IEmailDkimMaterial | undefined {
455
+ if (doc.activeDkim?.publicKey) return doc.activeDkim;
456
+ if (!doc.dkim?.publicKey) return undefined;
457
+ return {
458
+ selector: doc.dkim.selector || 'default',
459
+ keySize: doc.dkim.keySize || 2048,
460
+ publicKey: doc.dkim.publicKey,
461
+ createdAt: doc.createdAt,
462
+ };
399
463
  }
400
464
 
401
- private async checkMx(
402
- dnsClient: plugins.smartdns.dnsClientMod.Smartdns,
403
- domain: string,
404
- expectedValue?: string,
405
- ): Promise<TDnsRecordStatus> {
406
- try {
407
- const records = await dnsClient.getRecords(domain, 'MX');
408
- if (!records || records.length === 0) {
409
- return 'missing';
410
- }
411
- if (!expectedValue) {
412
- return 'valid';
413
- }
414
- const expected = this.normalizeMxValue(expectedValue);
415
- const found = records.some((record) => this.normalizeMxValue(record.value) === expected);
416
- return found ? 'valid' : 'invalid';
417
- } catch {
418
- return 'missing';
419
- }
465
+ private invalidateReconciliation(doc: EmailDomainDoc, code: string) {
466
+ return {
467
+ ...(doc.reconciliation || { desiredGeneration: 0, intents: [], errors: [] }),
468
+ lifecycleStatus: doc.reconciliation?.activeRevision ? 'active' as const : 'pending' as const,
469
+ desiredGeneration: (doc.reconciliation?.desiredGeneration || 0) + 1,
470
+ errors: [{ code, message: 'DNS reconciliation is pending', retryable: true }],
471
+ retryAt: new Date().toISOString(),
472
+ };
420
473
  }
421
474
 
422
- private async checkARecords(
423
- dnsClient: plugins.smartdns.dnsClientMod.Smartdns,
424
- name: string,
425
- expectedIps: string[],
426
- ): Promise<TDnsRecordStatus> {
427
- try {
428
- const records = await dnsClient.getRecordsA(name);
429
- if (!records || records.length === 0) {
430
- return 'missing';
431
- }
432
- const resolved = new Set(records.map((record) => record.value));
433
- return expectedIps.every((ip) => resolved.has(ip)) ? 'valid' : 'invalid';
434
- } catch {
435
- return 'missing';
436
- }
475
+ private capabilityError(code: string, message: string): Error {
476
+ const error = new Error(message) as Error & Record<string, unknown>;
477
+ error.code = code;
478
+ error.capability = 'unsupported';
479
+ error.errors = [{ code, message, retryable: false }];
480
+ return error;
437
481
  }
438
482
 
439
- private async checkTxtRecord(
440
- dnsClient: plugins.smartdns.dnsClientMod.Smartdns,
441
- name: string,
442
- expectedValue?: string,
443
- ): Promise<TDnsRecordStatus> {
444
- try {
445
- const records = await dnsClient.getRecordsTxt(name);
446
- if (!records || records.length === 0) {
447
- return 'missing';
448
- }
449
- if (!expectedValue) {
450
- return 'valid';
451
- }
452
- const found = records.some((record) => record.value.trim() === expectedValue.trim());
453
- return found ? 'valid' : 'invalid';
454
- } catch {
455
- return 'missing';
456
- }
483
+ private unsupportedResult(doc: EmailDomainDoc, code: string, message: string): IEmailDomainManagerActionResult {
484
+ return {
485
+ success: false,
486
+ lifecycleStatus: doc.reconciliation?.lifecycleStatus || 'pending',
487
+ code,
488
+ capability: 'unsupported',
489
+ message,
490
+ errors: [{ code, message, retryable: false }],
491
+ };
457
492
  }
458
493
 
459
- // ---------------------------------------------------------------------------
460
- // Helpers
461
- // ---------------------------------------------------------------------------
494
+ private hasDnsMutationFailure(doc: EmailDomainDoc): boolean {
495
+ return (doc.reconciliation?.intents || []).some((intent) => (
496
+ intent.status === 'failed' || intent.status === 'conflict'
497
+ )) || (doc.reconciliation?.errors || []).some((error) => (
498
+ ['DNS_PROVIDER_ZONE_FAILED', 'DNS_UNMANAGED_CONFLICT', 'DNS_CLEANUP_FAILED'].includes(error.code)
499
+ ));
500
+ }
501
+
502
+ private resultForDoc(doc: EmailDomainDoc, actionSucceeded: boolean): IEmailDomainManagerActionResult {
503
+ const state = doc.reconciliation;
504
+ const firstError = state?.errors[0];
505
+ return {
506
+ success: actionSucceeded,
507
+ lifecycleStatus: state?.lifecycleStatus || 'pending',
508
+ message: firstError?.message,
509
+ code: firstError?.code,
510
+ errors: state?.errors,
511
+ retryAt: state?.retryAt,
512
+ capability: state?.capability?.egressIdentityProof,
513
+ domain: this.docToInterface(doc),
514
+ };
515
+ }
462
516
 
463
517
  private docToInterface(doc: EmailDomainDoc): IEmailDomain {
464
518
  return {
@@ -470,65 +524,62 @@ export class EmailDomainManager {
470
524
  rateLimits: doc.rateLimits,
471
525
  remoteIngress: doc.remoteIngress,
472
526
  dnsStatus: doc.dnsStatus,
527
+ reconciliation: doc.reconciliation,
528
+ activeDkim: doc.activeDkim,
529
+ pendingDkim: doc.pendingDkim,
530
+ retiringDkim: doc.retiringDkim,
473
531
  createdAt: doc.createdAt,
474
532
  updatedAt: doc.updatedAt,
475
533
  };
476
534
  }
477
535
 
478
536
  private isDomainAlreadyConfigured(domainName: string): boolean {
479
- const configuredDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
480
- .map((domainConfig) => domainConfig.domain.toLowerCase());
481
- return configuredDomains.includes(domainName.toLowerCase());
537
+ return ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
538
+ .some((domainConfig) => domainConfig.domain.toLowerCase() === domainName.toLowerCase());
482
539
  }
483
540
 
484
541
  private async findLinkedDnsDomain(domainName: string): Promise<DomainDoc | null> {
485
542
  const domains = await DomainDoc.findAll();
486
543
  return domains
487
544
  .filter((domainDoc) => domainName === domainDoc.name || domainName.endsWith(`.${domainDoc.name}`))
488
- .sort((a, b) => b.name.length - a.name.length)[0] || null;
545
+ .sort((left, right) => right.name.length - left.name.length)[0] || null;
489
546
  }
490
547
 
491
548
  private async buildManagedDomainConfigs(): Promise<IEmailDomainConfig[]> {
492
549
  const docs = await EmailDomainDoc.findAll();
493
550
  const managedConfigs: IEmailDomainConfig[] = [];
494
-
495
551
  for (const doc of docs) {
496
- const linkedDomain = await DomainDoc.findById(doc.linkedDomainId);
497
- if (!linkedDomain) {
498
- logger.log('warn', `Skipping managed email domain ${doc.domain}: linked domain missing`);
552
+ if (doc.reconciliation?.lifecycleStatus === 'deleting') continue;
553
+ if (!await DomainDoc.findById(doc.linkedDomainId)) {
554
+ logger.log('warn', `Managed email domain ${doc.domain} has no linked DNS domain; keeping it out of runtime`);
499
555
  continue;
500
556
  }
501
-
557
+ const activeDkim = this.getActiveDkim(doc);
558
+ const dkimConfig = activeDkim && (activeDkim.selector === 'default' || this.selectorCorrectSigningCapability)
559
+ ? {
560
+ selector: activeDkim.selector,
561
+ keySize: activeDkim.keySize,
562
+ // Dcrouter exclusively owns staged external rotation.
563
+ rotateKeys: false,
564
+ rotationInterval: doc.dkim.rotationIntervalDays,
565
+ }
566
+ : undefined;
502
567
  managedConfigs.push({
503
568
  domain: doc.domain,
504
- // Always external-dns: DcRouter owns the DNS records for managed domains
505
- // (email-domain-manager DnsRecordDocs). internal-dns would make smartmta
506
- // self-register a conflicting MX (exchange = the domain itself) plus
507
- // duplicate SPF/DKIM TXT handlers on the shared DNS server.
508
569
  dnsMode: 'external-dns',
509
- dkim: {
510
- selector: doc.dkim.selector,
511
- keySize: doc.dkim.keySize,
512
- rotateKeys: doc.dkim.rotateKeys,
513
- rotationInterval: doc.dkim.rotationIntervalDays,
514
- },
570
+ ...(dkimConfig ? { dkim: dkimConfig } : {}),
515
571
  rateLimits: doc.rateLimits,
516
572
  });
517
573
  }
518
-
519
574
  return managedConfigs;
520
575
  }
521
576
 
522
577
  public async syncManagedDomainsToRuntime(): Promise<void> {
523
- if (!this.dcRouter.options?.emailConfig) {
524
- return;
525
- }
526
-
578
+ if (!this.dcRouter.options?.emailConfig) return;
527
579
  const mergedDomains = new Map<string, IEmailDomainConfig>();
528
580
  for (const domainConfig of this.baseEmailDomains) {
529
581
  mergedDomains.set(domainConfig.domain.toLowerCase(), JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
530
582
  }
531
-
532
583
  for (const managedConfig of await this.buildManagedDomainConfigs()) {
533
584
  const key = managedConfig.domain.toLowerCase();
534
585
  if (mergedDomains.has(key)) {
@@ -537,11 +588,8 @@ export class EmailDomainManager {
537
588
  }
538
589
  mergedDomains.set(key, managedConfig);
539
590
  }
540
-
541
- const domains = Array.from(mergedDomains.values());
591
+ const domains = [...mergedDomains.values()];
542
592
  this.dcRouter.options.emailConfig.domains = domains;
543
- if (this.dcRouter.emailServer) {
544
- this.dcRouter.emailServer.updateOptions({ domains });
545
- }
593
+ this.dcRouter.emailServer?.updateOptions({ domains });
546
594
  }
547
595
  }