@serve.zone/dcrouter 16.4.1 → 16.5.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.
- package/deno.json +1 -1
- package/dist_serve/bundle.js +852 -852
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.d.ts +2 -1
- package/dist_ts/classes.dcrouter.js +20 -4
- package/dist_ts/db/documents/classes.dns-record.doc.d.ts +4 -0
- package/dist_ts/db/documents/classes.dns-record.doc.js +33 -2
- package/dist_ts/db/documents/classes.email-domain.doc.d.ts +5 -1
- package/dist_ts/db/documents/classes.email-domain.doc.js +26 -2
- package/dist_ts/dns/manager.dns.d.ts +6 -0
- package/dist_ts/dns/manager.dns.js +81 -32
- package/dist_ts/dns/providers/cloudflare.provider.d.ts +2 -0
- package/dist_ts/dns/providers/cloudflare.provider.js +45 -28
- package/dist_ts/email/classes.email-domain.manager.d.ts +43 -38
- package/dist_ts/email/classes.email-domain.manager.js +391 -324
- package/dist_ts/email/classes.mail-dns-sync.d.ts +58 -17
- package/dist_ts/email/classes.mail-dns-sync.js +857 -141
- package/dist_ts/email/classes.mail-edge-eligibility.d.ts +55 -0
- package/dist_ts/email/classes.mail-edge-eligibility.js +168 -0
- package/dist_ts/email/classes.mail-egress-coordinator.d.ts +3 -1
- package/dist_ts/email/classes.mail-egress-coordinator.js +43 -22
- package/dist_ts/email/classes.workapp-mail-manager.d.ts +3 -0
- package/dist_ts/email/classes.workapp-mail-manager.js +58 -12
- package/dist_ts/email/index.d.ts +1 -0
- package/dist_ts/email/index.js +2 -1
- package/dist_ts/opsserver/handlers/email-domain.handler.d.ts +1 -0
- package/dist_ts/opsserver/handlers/email-domain.handler.js +138 -22
- package/dist_ts/remoteingress/classes.hub-lifecycle.js +2 -1
- package/dist_ts/remoteingress/classes.tunnel-manager.d.ts +6 -0
- package/dist_ts/remoteingress/classes.tunnel-manager.js +40 -2
- package/dist_ts_interfaces/data/dns-record.d.ts +6 -0
- package/dist_ts_interfaces/data/email-domain.d.ts +81 -0
- package/dist_ts_interfaces/requests/email-domains.d.ts +25 -17
- package/dist_ts_migrations/index.js +152 -9
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/appstate/email-domains.d.ts +16 -0
- package/dist_ts_web/appstate/email-domains.js +140 -19
- package/dist_ts_web/elements/email/ops-view-email-domains.d.ts +1 -0
- package/dist_ts_web/elements/email/ops-view-email-domains.js +36 -13
- package/package.json +3 -3
- package/readme.md +13 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +22 -3
- package/ts/db/documents/classes.dns-record.doc.ts +20 -0
- package/ts/db/documents/classes.email-domain.doc.ts +14 -0
- package/ts/dns/manager.dns.ts +89 -29
- package/ts/dns/providers/cloudflare.provider.ts +46 -27
- package/ts/email/classes.email-domain.manager.ts +416 -368
- package/ts/email/classes.mail-dns-sync.ts +913 -146
- package/ts/email/classes.mail-edge-eligibility.ts +205 -0
- package/ts/email/classes.mail-egress-coordinator.ts +46 -23
- package/ts/email/classes.workapp-mail-manager.ts +69 -13
- package/ts/email/index.ts +1 -0
- package/ts/opsserver/handlers/email-domain.handler.ts +154 -21
- package/ts/remoteingress/classes.hub-lifecycle.ts +1 -0
- package/ts/remoteingress/classes.tunnel-manager.ts +53 -7
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/appstate/email-domains.ts +187 -18
- 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 {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
*
|
|
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
|
|
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.
|
|
52
|
-
|
|
63
|
+
await this.mutationChain.catch(() => undefined);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
public supportsSelectorCorrectSigning(): boolean {
|
|
67
|
+
return this.selectorCorrectSigningCapability;
|
|
53
68
|
}
|
|
54
69
|
|
|
55
|
-
|
|
56
|
-
|
|
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((
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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<
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
293
|
+
return this.resultForDoc(remaining, remaining.reconciliation?.errors.length === 0);
|
|
214
294
|
}
|
|
215
295
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
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
|
|
229
|
-
|
|
230
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
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
|
|
381
|
-
|
|
382
|
-
|
|
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
|
|
446
|
+
return { edgeFilter };
|
|
385
447
|
}
|
|
386
448
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
return
|
|
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
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
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
|
-
|
|
480
|
-
.
|
|
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((
|
|
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
|
-
|
|
497
|
-
if (!
|
|
498
|
-
logger.log('warn', `
|
|
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
|
-
|
|
544
|
-
this.dcRouter.emailServer.updateOptions({ domains });
|
|
545
|
-
}
|
|
593
|
+
this.dcRouter.emailServer?.updateOptions({ domains });
|
|
546
594
|
}
|
|
547
595
|
}
|