@serve.zone/dcrouter 13.17.9 → 13.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist_serve/bundle.js +6 -5
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/classes.dcrouter.d.ts +9 -5
  4. package/dist_ts/classes.dcrouter.js +152 -120
  5. package/dist_ts/config/classes.route-config-manager.d.ts +13 -5
  6. package/dist_ts/config/classes.route-config-manager.js +76 -36
  7. package/dist_ts/db/documents/classes.route.doc.d.ts +2 -0
  8. package/dist_ts/db/documents/classes.route.doc.js +11 -2
  9. package/dist_ts/email/classes.email-domain.manager.d.ts +7 -0
  10. package/dist_ts/email/classes.email-domain.manager.js +118 -55
  11. package/dist_ts/email/classes.smartmta-storage-manager.d.ts +13 -0
  12. package/dist_ts/email/classes.smartmta-storage-manager.js +101 -0
  13. package/dist_ts/email/email-dns-records.d.ts +14 -0
  14. package/dist_ts/email/email-dns-records.js +34 -0
  15. package/dist_ts/email/index.d.ts +2 -0
  16. package/dist_ts/email/index.js +3 -1
  17. package/dist_ts/opsserver/handlers/email-ops.handler.js +6 -15
  18. package/dist_ts/opsserver/handlers/route-management.handler.js +5 -7
  19. package/dist_ts/opsserver/handlers/stats.handler.js +41 -7
  20. package/dist_ts_interfaces/data/route-management.d.ts +2 -0
  21. package/dist_ts_migrations/index.js +25 -1
  22. package/dist_ts_web/00_commitinfo_data.js +1 -1
  23. package/dist_ts_web/appstate.js +13 -4
  24. package/dist_ts_web/elements/network/ops-view-routes.d.ts +2 -0
  25. package/dist_ts_web/elements/network/ops-view-routes.js +44 -21
  26. package/package.json +2 -2
  27. package/readme.md +190 -1543
  28. package/ts/00_commitinfo_data.ts +1 -1
  29. package/ts/classes.dcrouter.ts +190 -138
  30. package/ts/config/classes.route-config-manager.ts +97 -42
  31. package/ts/db/documents/classes.route.doc.ts +7 -0
  32. package/ts/email/classes.email-domain.manager.ts +136 -51
  33. package/ts/email/classes.smartmta-storage-manager.ts +108 -0
  34. package/ts/email/email-dns-records.ts +53 -0
  35. package/ts/email/index.ts +2 -0
  36. package/ts/opsserver/handlers/email-ops.handler.ts +5 -19
  37. package/ts/opsserver/handlers/route-management.handler.ts +4 -6
  38. package/ts/opsserver/handlers/stats.handler.ts +43 -7
  39. package/ts_apiclient/readme.md +69 -195
  40. package/ts_web/00_commitinfo_data.ts +1 -1
  41. package/ts_web/appstate.ts +16 -4
  42. package/ts_web/elements/network/ops-view-routes.ts +47 -29
  43. package/ts_web/readme.md +41 -242
@@ -14,6 +14,11 @@ import type { ReferenceResolver } from './classes.reference-resolver.js';
14
14
  /** An IP allow entry: plain IP/CIDR or domain-scoped. */
15
15
  export type TIpAllowEntry = string | { ip: string; domains: string[] };
16
16
 
17
+ export interface IRouteMutationResult {
18
+ success: boolean;
19
+ message?: string;
20
+ }
21
+
17
22
  /**
18
23
  * Simple async mutex — serializes concurrent applyRoutes() calls so the Rust engine
19
24
  * never receives rapid overlapping route updates that can churn UDP/QUIC listeners.
@@ -56,6 +61,7 @@ export class RouteConfigManager {
56
61
  private referenceResolver?: ReferenceResolver,
57
62
  private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
58
63
  private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[],
64
+ private hydrateStoredRoute?: (storedRoute: IRoute) => plugins.smartproxy.IRouteConfig | undefined,
59
65
  ) {}
60
66
 
61
67
  /** Expose routes map for reference resolution lookups. */
@@ -63,6 +69,10 @@ export class RouteConfigManager {
63
69
  return this.routes;
64
70
  }
65
71
 
72
+ public getRoute(id: string): IRoute | undefined {
73
+ return this.routes.get(id);
74
+ }
75
+
66
76
  /**
67
77
  * Load persisted routes, seed serializable config/email/dns routes,
68
78
  * compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy.
@@ -94,6 +104,7 @@ export class RouteConfigManager {
94
104
  id: route.id,
95
105
  enabled: route.enabled,
96
106
  origin: route.origin,
107
+ systemKey: route.systemKey,
97
108
  createdAt: route.createdAt,
98
109
  updatedAt: route.updatedAt,
99
110
  metadata: route.metadata,
@@ -153,9 +164,21 @@ export class RouteConfigManager {
153
164
  enabled?: boolean;
154
165
  metadata?: Partial<IRouteMetadata>;
155
166
  },
156
- ): Promise<boolean> {
167
+ ): Promise<IRouteMutationResult> {
157
168
  const stored = this.routes.get(id);
158
- if (!stored) return false;
169
+ if (!stored) {
170
+ return { success: false, message: 'Route not found' };
171
+ }
172
+
173
+ const isToggleOnlyPatch = patch.enabled !== undefined
174
+ && patch.route === undefined
175
+ && patch.metadata === undefined;
176
+ if (stored.origin !== 'api' && !isToggleOnlyPatch) {
177
+ return {
178
+ success: false,
179
+ message: 'System routes are managed by the system and can only be toggled',
180
+ };
181
+ }
159
182
 
160
183
  if (patch.route) {
161
184
  const mergedAction = patch.route.action
@@ -189,19 +212,29 @@ export class RouteConfigManager {
189
212
 
190
213
  await this.persistRoute(stored);
191
214
  await this.applyRoutes();
192
- return true;
215
+ return { success: true };
193
216
  }
194
217
 
195
- public async deleteRoute(id: string): Promise<boolean> {
196
- if (!this.routes.has(id)) return false;
218
+ public async deleteRoute(id: string): Promise<IRouteMutationResult> {
219
+ const stored = this.routes.get(id);
220
+ if (!stored) {
221
+ return { success: false, message: 'Route not found' };
222
+ }
223
+ if (stored.origin !== 'api') {
224
+ return {
225
+ success: false,
226
+ message: 'System routes are managed by the system and cannot be deleted',
227
+ };
228
+ }
229
+
197
230
  this.routes.delete(id);
198
231
  const doc = await RouteDoc.findById(id);
199
232
  if (doc) await doc.delete();
200
233
  await this.applyRoutes();
201
- return true;
234
+ return { success: true };
202
235
  }
203
236
 
204
- public async toggleRoute(id: string, enabled: boolean): Promise<boolean> {
237
+ public async toggleRoute(id: string, enabled: boolean): Promise<IRouteMutationResult> {
205
238
  return this.updateRoute(id, { enabled });
206
239
  }
207
240
 
@@ -217,29 +250,28 @@ export class RouteConfigManager {
217
250
  seedRoutes: IDcRouterRouteConfig[],
218
251
  origin: 'config' | 'email' | 'dns',
219
252
  ): Promise<void> {
220
- if (seedRoutes.length === 0) return;
221
-
253
+ const seedSystemKeys = new Set<string>();
222
254
  const seedNames = new Set<string>();
223
255
  let seeded = 0;
224
256
  let updated = 0;
225
257
 
226
258
  for (const route of seedRoutes) {
227
259
  const name = route.name || '';
228
- seedNames.add(name);
229
-
230
- // Check if a route with this name+origin already exists in memory
231
- let existingId: string | undefined;
232
- for (const [id, r] of this.routes) {
233
- if (r.origin === origin && r.route.name === name) {
234
- existingId = id;
235
- break;
236
- }
260
+ if (name) {
261
+ seedNames.add(name);
262
+ }
263
+ const systemKey = this.buildSystemRouteKey(origin, route);
264
+ if (systemKey) {
265
+ seedSystemKeys.add(systemKey);
237
266
  }
238
267
 
268
+ const existingId = this.findExistingSeedRouteId(origin, route, systemKey);
269
+
239
270
  if (existingId) {
240
271
  // Update route config but preserve enabled state
241
272
  const existing = this.routes.get(existingId)!;
242
273
  existing.route = route;
274
+ existing.systemKey = systemKey;
243
275
  existing.updatedAt = Date.now();
244
276
  await this.persistRoute(existing);
245
277
  updated++;
@@ -255,6 +287,7 @@ export class RouteConfigManager {
255
287
  updatedAt: now,
256
288
  createdBy: 'system',
257
289
  origin,
290
+ systemKey,
258
291
  };
259
292
  this.routes.set(id, newRoute);
260
293
  await this.persistRoute(newRoute);
@@ -265,7 +298,12 @@ export class RouteConfigManager {
265
298
  // Delete stale routes: same origin but name not in current seed set
266
299
  const staleIds: string[] = [];
267
300
  for (const [id, r] of this.routes) {
268
- if (r.origin === origin && !seedNames.has(r.route.name || '')) {
301
+ if (r.origin !== origin) continue;
302
+
303
+ const routeName = r.route.name || '';
304
+ const matchesSeedSystemKey = r.systemKey ? seedSystemKeys.has(r.systemKey) : false;
305
+ const matchesSeedName = routeName ? seedNames.has(routeName) : false;
306
+ if (!matchesSeedSystemKey && !matchesSeedName) {
269
307
  staleIds.push(id);
270
308
  }
271
309
  }
@@ -284,9 +322,39 @@ export class RouteConfigManager {
284
322
  // Private: persistence
285
323
  // =========================================================================
286
324
 
325
+ private buildSystemRouteKey(
326
+ origin: 'config' | 'email' | 'dns',
327
+ route: IDcRouterRouteConfig,
328
+ ): string | undefined {
329
+ const name = route.name?.trim();
330
+ if (!name) return undefined;
331
+ return `${origin}:${name}`;
332
+ }
333
+
334
+ private findExistingSeedRouteId(
335
+ origin: 'config' | 'email' | 'dns',
336
+ route: IDcRouterRouteConfig,
337
+ systemKey?: string,
338
+ ): string | undefined {
339
+ const routeName = route.name || '';
340
+
341
+ for (const [id, storedRoute] of this.routes) {
342
+ if (storedRoute.origin !== origin) continue;
343
+
344
+ if (systemKey && storedRoute.systemKey === systemKey) {
345
+ return id;
346
+ }
347
+
348
+ if (storedRoute.route.name === routeName) {
349
+ return id;
350
+ }
351
+ }
352
+
353
+ return undefined;
354
+ }
355
+
287
356
  private async loadRoutes(): Promise<void> {
288
357
  const docs = await RouteDoc.findAll();
289
- let prunedRuntimeRoutes = 0;
290
358
 
291
359
  for (const doc of docs) {
292
360
  if (!doc.id) continue;
@@ -299,27 +367,15 @@ export class RouteConfigManager {
299
367
  updatedAt: doc.updatedAt,
300
368
  createdBy: doc.createdBy,
301
369
  origin: doc.origin || 'api',
370
+ systemKey: doc.systemKey,
302
371
  metadata: doc.metadata,
303
372
  };
304
373
 
305
- if (this.isPersistedRuntimeRoute(storedRoute)) {
306
- await doc.delete();
307
- prunedRuntimeRoutes++;
308
- logger.log(
309
- 'warn',
310
- `Removed persisted runtime-only route '${storedRoute.route.name || storedRoute.id}' (${storedRoute.id}) from RouteDoc`,
311
- );
312
- continue;
313
- }
314
-
315
374
  this.routes.set(doc.id, storedRoute);
316
375
  }
317
376
  if (this.routes.size > 0) {
318
377
  logger.log('info', `Loaded ${this.routes.size} route(s) from database`);
319
378
  }
320
- if (prunedRuntimeRoutes > 0) {
321
- logger.log('info', `Pruned ${prunedRuntimeRoutes} persisted runtime-only route(s) from RouteDoc`);
322
- }
323
379
  }
324
380
 
325
381
  private async persistRoute(stored: IRoute): Promise<void> {
@@ -330,6 +386,7 @@ export class RouteConfigManager {
330
386
  existingDoc.updatedAt = stored.updatedAt;
331
387
  existingDoc.createdBy = stored.createdBy;
332
388
  existingDoc.origin = stored.origin;
389
+ existingDoc.systemKey = stored.systemKey;
333
390
  existingDoc.metadata = stored.metadata;
334
391
  await existingDoc.save();
335
392
  } else {
@@ -341,6 +398,7 @@ export class RouteConfigManager {
341
398
  doc.updatedAt = stored.updatedAt;
342
399
  doc.createdBy = stored.createdBy;
343
400
  doc.origin = stored.origin;
401
+ doc.systemKey = stored.systemKey;
344
402
  doc.metadata = stored.metadata;
345
403
  await doc.save();
346
404
  }
@@ -411,7 +469,7 @@ export class RouteConfigManager {
411
469
  // Add all enabled routes with HTTP/3 and VPN augmentation
412
470
  for (const route of this.routes.values()) {
413
471
  if (route.enabled) {
414
- enabledRoutes.push(this.prepareRouteForApply(route.route, route.id));
472
+ enabledRoutes.push(this.prepareStoredRouteForApply(route));
415
473
  }
416
474
  }
417
475
 
@@ -431,6 +489,11 @@ export class RouteConfigManager {
431
489
  });
432
490
  }
433
491
 
492
+ private prepareStoredRouteForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig {
493
+ const hydratedRoute = this.hydrateStoredRoute?.(storedRoute);
494
+ return this.prepareRouteForApply(hydratedRoute || storedRoute.route, storedRoute.id);
495
+ }
496
+
434
497
  private prepareRouteForApply(
435
498
  route: plugins.smartproxy.IRouteConfig,
436
499
  routeId?: string,
@@ -465,12 +528,4 @@ export class RouteConfigManager {
465
528
  },
466
529
  };
467
530
  }
468
-
469
- private isPersistedRuntimeRoute(storedRoute: IRoute): boolean {
470
- const routeName = storedRoute.route.name || '';
471
- const actionType = storedRoute.route.action?.type;
472
-
473
- return (routeName.startsWith('dns-over-https-') && actionType === 'socket-handler')
474
- || (storedRoute.origin === 'dns' && actionType === 'socket-handler');
475
- }
476
531
  }
@@ -29,6 +29,9 @@ export class RouteDoc extends plugins.smartdata.SmartDataDbDoc<RouteDoc, RouteDo
29
29
  @plugins.smartdata.svDb()
30
30
  public origin!: 'config' | 'email' | 'dns' | 'api';
31
31
 
32
+ @plugins.smartdata.svDb()
33
+ public systemKey?: string;
34
+
32
35
  @plugins.smartdata.svDb()
33
36
  public metadata?: IRouteMetadata;
34
37
 
@@ -51,4 +54,8 @@ export class RouteDoc extends plugins.smartdata.SmartDataDbDoc<RouteDoc, RouteDo
51
54
  public static async findByOrigin(origin: 'config' | 'email' | 'dns' | 'api'): Promise<RouteDoc[]> {
52
55
  return await RouteDoc.getInstances({ origin });
53
56
  }
57
+
58
+ public static async findBySystemKey(systemKey: string): Promise<RouteDoc | null> {
59
+ return await RouteDoc.getInstance({ systemKey });
60
+ }
54
61
  }
@@ -1,10 +1,12 @@
1
1
  import * as plugins from '../plugins.js';
2
+ import type { IEmailDomainConfig } from '@push.rocks/smartmta';
2
3
  import { logger } from '../logger.js';
3
4
  import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
4
5
  import { DomainDoc } from '../db/documents/classes.domain.doc.js';
5
6
  import { DnsRecordDoc } from '../db/documents/classes.dns-record.doc.js';
6
7
  import type { DnsManager } from '../dns/manager.dns.js';
7
8
  import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_interfaces/data/email-domain.js';
9
+ import { buildEmailDnsRecords } from './email-dns-records.js';
8
10
 
9
11
  /**
10
12
  * EmailDomainManager — orchestrates email domain setup.
@@ -15,9 +17,12 @@ import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_i
15
17
  */
16
18
  export class EmailDomainManager {
17
19
  private dcRouter: any; // DcRouter — avoids circular import
20
+ private readonly baseEmailDomains: IEmailDomainConfig[];
18
21
 
19
22
  constructor(dcRouterRef: any) {
20
23
  this.dcRouter = dcRouterRef;
24
+ this.baseEmailDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
25
+ .map((domainConfig) => JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
21
26
  }
22
27
 
23
28
  private get dnsManager(): DnsManager | undefined {
@@ -32,6 +37,12 @@ export class EmailDomainManager {
32
37
  return this.dcRouter.options?.emailConfig?.hostname || this.dcRouter.options?.tls?.domain || 'localhost';
33
38
  }
34
39
 
40
+ public async start(): Promise<void> {
41
+ await this.syncManagedDomainsToRuntime();
42
+ }
43
+
44
+ public async stop(): Promise<void> {}
45
+
35
46
  // ---------------------------------------------------------------------------
36
47
  // CRUD
37
48
  // ---------------------------------------------------------------------------
@@ -64,6 +75,9 @@ export class EmailDomainManager {
64
75
  const domainName = subdomain ? `${subdomain}.${baseDomain}` : baseDomain;
65
76
 
66
77
  // Check for duplicates
78
+ if (this.isDomainAlreadyConfigured(domainName)) {
79
+ throw new Error(`Email domain already configured for ${domainName}`);
80
+ }
67
81
  const existing = await EmailDomainDoc.findByDomain(domainName);
68
82
  if (existing) {
69
83
  throw new Error(`Email domain already exists for ${domainName}`);
@@ -77,8 +91,8 @@ export class EmailDomainManager {
77
91
  let publicKey: string | undefined;
78
92
  if (this.dkimCreator) {
79
93
  try {
80
- await this.dkimCreator.handleDKIMKeysForDomain(domainName);
81
- const dnsRecord = await this.dkimCreator.getDNSRecordForSelector(domainName, selector);
94
+ await this.dkimCreator.handleDKIMKeysForSelector(domainName, selector, keySize);
95
+ const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domainName, selector);
82
96
  // Extract public key from the DNS record value
83
97
  const match = dnsRecord?.value?.match(/p=([A-Za-z0-9+/=]+)/);
84
98
  publicKey = match ? match[1] : undefined;
@@ -110,6 +124,7 @@ export class EmailDomainManager {
110
124
  doc.createdAt = now;
111
125
  doc.updatedAt = now;
112
126
  await doc.save();
127
+ await this.syncManagedDomainsToRuntime();
113
128
 
114
129
  logger.log('info', `Email domain created: ${domainName}`);
115
130
  return this.docToInterface(doc);
@@ -131,12 +146,14 @@ export class EmailDomainManager {
131
146
  if (changes.rateLimits !== undefined) doc.rateLimits = changes.rateLimits;
132
147
  doc.updatedAt = new Date().toISOString();
133
148
  await doc.save();
149
+ await this.syncManagedDomainsToRuntime();
134
150
  }
135
151
 
136
152
  public async deleteEmailDomain(id: string): Promise<void> {
137
153
  const doc = await EmailDomainDoc.findById(id);
138
154
  if (!doc) throw new Error(`Email domain not found: ${id}`);
139
155
  await doc.delete();
156
+ await this.syncManagedDomainsToRuntime();
140
157
  logger.log('info', `Email domain deleted: ${doc.domain}`);
141
158
  }
142
159
 
@@ -153,37 +170,25 @@ export class EmailDomainManager {
153
170
 
154
171
  const domain = doc.domain;
155
172
  const selector = doc.dkim.selector;
156
- const publicKey = doc.dkim.publicKey || '';
157
173
  const hostname = this.emailHostname;
174
+ let dkimValue = `v=DKIM1; h=sha256; k=rsa; p=${doc.dkim.publicKey || ''}`;
175
+
176
+ if (this.dkimCreator) {
177
+ try {
178
+ const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domain, selector);
179
+ dkimValue = dnsRecord.value;
180
+ } catch (err: unknown) {
181
+ logger.log('warn', `Failed to load DKIM DNS record for ${domain}: ${(err as Error).message}`);
182
+ }
183
+ }
158
184
 
159
- const records: IEmailDnsRecord[] = [
160
- {
161
- type: 'MX',
162
- name: domain,
163
- value: `10 ${hostname}`,
164
- status: doc.dnsStatus.mx,
165
- },
166
- {
167
- type: 'TXT',
168
- name: domain,
169
- value: 'v=spf1 a mx ~all',
170
- status: doc.dnsStatus.spf,
171
- },
172
- {
173
- type: 'TXT',
174
- name: `${selector}._domainkey.${domain}`,
175
- value: `v=DKIM1; h=sha256; k=rsa; p=${publicKey}`,
176
- status: doc.dnsStatus.dkim,
177
- },
178
- {
179
- type: 'TXT',
180
- name: `_dmarc.${domain}`,
181
- value: `v=DMARC1; p=none; rua=mailto:dmarc@${domain}`,
182
- status: doc.dnsStatus.dmarc,
183
- },
184
- ];
185
-
186
- return records;
185
+ return buildEmailDnsRecords({
186
+ domain,
187
+ hostname,
188
+ selector,
189
+ dkimValue,
190
+ statuses: doc.dnsStatus,
191
+ });
187
192
  }
188
193
 
189
194
  // ---------------------------------------------------------------------------
@@ -207,17 +212,7 @@ export class EmailDomainManager {
207
212
 
208
213
  for (const required of requiredRecords) {
209
214
  // Check if a matching record already exists
210
- const exists = existingRecords.some((r) => {
211
- if (required.type === 'MX') {
212
- return r.type === 'MX' && r.name.toLowerCase() === required.name.toLowerCase();
213
- }
214
- // For TXT records, match by name AND check value prefix (v=spf1, v=DKIM1, v=DMARC1)
215
- if (r.type !== 'TXT' || r.name.toLowerCase() !== required.name.toLowerCase()) return false;
216
- if (required.value.startsWith('v=spf1')) return r.value.includes('v=spf1');
217
- if (required.value.startsWith('v=DKIM1')) return r.value.includes('v=DKIM1');
218
- if (required.value.startsWith('v=DMARC1')) return r.value.includes('v=DMARC1');
219
- return false;
220
- });
215
+ const exists = existingRecords.some((r) => this.recordMatchesRequired(r, required));
221
216
 
222
217
  if (!exists) {
223
218
  try {
@@ -259,16 +254,23 @@ export class EmailDomainManager {
259
254
  const resolver = new plugins.dns.promises.Resolver();
260
255
 
261
256
  // MX check
262
- doc.dnsStatus.mx = await this.checkMx(resolver, domain);
257
+ const requiredRecords = await this.getRequiredDnsRecords(id);
258
+
259
+ const mxRecord = requiredRecords.find((record) => record.type === 'MX');
260
+ const spfRecord = requiredRecords.find((record) => record.name === domain && record.value.startsWith('v=spf1'));
261
+ const dkimRecord = requiredRecords.find((record) => record.name === `${selector}._domainkey.${domain}`);
262
+ const dmarcRecord = requiredRecords.find((record) => record.name === `_dmarc.${domain}`);
263
+
264
+ doc.dnsStatus.mx = await this.checkMx(resolver, domain, mxRecord?.value);
263
265
 
264
266
  // SPF check
265
- doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, 'v=spf1');
267
+ doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, spfRecord?.value);
266
268
 
267
269
  // DKIM check
268
- doc.dnsStatus.dkim = await this.checkTxtRecord(resolver, `${selector}._domainkey.${domain}`, 'v=DKIM1');
270
+ doc.dnsStatus.dkim = await this.checkTxtRecord(resolver, `${selector}._domainkey.${domain}`, dkimRecord?.value);
269
271
 
270
272
  // DMARC check
271
- doc.dnsStatus.dmarc = await this.checkTxtRecord(resolver, `_dmarc.${domain}`, 'v=DMARC1');
273
+ doc.dnsStatus.dmarc = await this.checkTxtRecord(resolver, `_dmarc.${domain}`, dmarcRecord?.value);
272
274
 
273
275
  doc.dnsStatus.lastCheckedAt = new Date().toISOString();
274
276
  doc.updatedAt = new Date().toISOString();
@@ -277,10 +279,28 @@ export class EmailDomainManager {
277
279
  return this.getRequiredDnsRecords(id);
278
280
  }
279
281
 
280
- private async checkMx(resolver: plugins.dns.promises.Resolver, domain: string): Promise<TDnsRecordStatus> {
282
+ private recordMatchesRequired(record: DnsRecordDoc, required: IEmailDnsRecord): boolean {
283
+ if (record.type !== required.type || record.name.toLowerCase() !== required.name.toLowerCase()) {
284
+ return false;
285
+ }
286
+ return record.value.trim() === required.value.trim();
287
+ }
288
+
289
+ private async checkMx(
290
+ resolver: plugins.dns.promises.Resolver,
291
+ domain: string,
292
+ expectedValue?: string,
293
+ ): Promise<TDnsRecordStatus> {
281
294
  try {
282
295
  const records = await resolver.resolveMx(domain);
283
- return records && records.length > 0 ? 'valid' : 'missing';
296
+ if (!records || records.length === 0) {
297
+ return 'missing';
298
+ }
299
+ if (!expectedValue) {
300
+ return 'valid';
301
+ }
302
+ const found = records.some((record) => `${record.priority} ${record.exchange}`.trim() === expectedValue.trim());
303
+ return found ? 'valid' : 'invalid';
284
304
  } catch {
285
305
  return 'missing';
286
306
  }
@@ -289,13 +309,19 @@ export class EmailDomainManager {
289
309
  private async checkTxtRecord(
290
310
  resolver: plugins.dns.promises.Resolver,
291
311
  name: string,
292
- prefix: string,
312
+ expectedValue?: string,
293
313
  ): Promise<TDnsRecordStatus> {
294
314
  try {
295
315
  const records = await resolver.resolveTxt(name);
296
316
  const flat = records.map((r) => r.join(''));
297
- const found = flat.some((r) => r.startsWith(prefix));
298
- return found ? 'valid' : 'missing';
317
+ if (flat.length === 0) {
318
+ return 'missing';
319
+ }
320
+ if (!expectedValue) {
321
+ return 'valid';
322
+ }
323
+ const found = flat.some((record) => record.trim() === expectedValue.trim());
324
+ return found ? 'valid' : 'invalid';
299
325
  } catch {
300
326
  return 'missing';
301
327
  }
@@ -318,4 +344,63 @@ export class EmailDomainManager {
318
344
  updatedAt: doc.updatedAt,
319
345
  };
320
346
  }
347
+
348
+ private isDomainAlreadyConfigured(domainName: string): boolean {
349
+ const configuredDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
350
+ .map((domainConfig) => domainConfig.domain.toLowerCase());
351
+ return configuredDomains.includes(domainName.toLowerCase());
352
+ }
353
+
354
+ private async buildManagedDomainConfigs(): Promise<IEmailDomainConfig[]> {
355
+ const docs = await EmailDomainDoc.findAll();
356
+ const managedConfigs: IEmailDomainConfig[] = [];
357
+
358
+ for (const doc of docs) {
359
+ const linkedDomain = await DomainDoc.findById(doc.linkedDomainId);
360
+ if (!linkedDomain) {
361
+ logger.log('warn', `Skipping managed email domain ${doc.domain}: linked domain missing`);
362
+ continue;
363
+ }
364
+
365
+ managedConfigs.push({
366
+ domain: doc.domain,
367
+ dnsMode: linkedDomain.source === 'dcrouter' ? 'internal-dns' : 'external-dns',
368
+ dkim: {
369
+ selector: doc.dkim.selector,
370
+ keySize: doc.dkim.keySize,
371
+ rotateKeys: doc.dkim.rotateKeys,
372
+ rotationInterval: doc.dkim.rotationIntervalDays,
373
+ },
374
+ rateLimits: doc.rateLimits,
375
+ });
376
+ }
377
+
378
+ return managedConfigs;
379
+ }
380
+
381
+ private async syncManagedDomainsToRuntime(): Promise<void> {
382
+ if (!this.dcRouter.options?.emailConfig) {
383
+ return;
384
+ }
385
+
386
+ const mergedDomains = new Map<string, IEmailDomainConfig>();
387
+ for (const domainConfig of this.baseEmailDomains) {
388
+ mergedDomains.set(domainConfig.domain.toLowerCase(), JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
389
+ }
390
+
391
+ for (const managedConfig of await this.buildManagedDomainConfigs()) {
392
+ const key = managedConfig.domain.toLowerCase();
393
+ if (mergedDomains.has(key)) {
394
+ logger.log('warn', `Managed email domain ${managedConfig.domain} duplicates a configured domain; keeping the configured definition`);
395
+ continue;
396
+ }
397
+ mergedDomains.set(key, managedConfig);
398
+ }
399
+
400
+ const domains = Array.from(mergedDomains.values());
401
+ this.dcRouter.options.emailConfig.domains = domains;
402
+ if (this.dcRouter.emailServer) {
403
+ this.dcRouter.emailServer.updateOptions({ domains });
404
+ }
405
+ }
321
406
  }