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