@serve.zone/dcrouter 15.11.0 → 16.0.1

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 (73) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +34 -34
  3. package/dist_ts/00_commitinfo_data.js +2 -2
  4. package/dist_ts/classes.dcrouter.d.ts +10 -3
  5. package/dist_ts/classes.dcrouter.js +39 -43
  6. package/dist_ts/db/documents/classes.cached.email.d.ts +32 -0
  7. package/dist_ts/db/documents/classes.cached.email.js +67 -3
  8. package/dist_ts/db/documents/classes.email-domain.doc.d.ts +2 -1
  9. package/dist_ts/db/documents/classes.email-domain.doc.js +8 -2
  10. package/dist_ts/db/documents/classes.email-server-settings.doc.js +2 -2
  11. package/dist_ts/db/documents/classes.remote-ingress-edge.doc.d.ts +3 -0
  12. package/dist_ts/db/documents/classes.remote-ingress-edge.doc.js +20 -2
  13. package/dist_ts/dns/classes.dns-server-runtime.js +3 -1
  14. package/dist_ts/email/classes.accepted-email-spool.d.ts +5 -1
  15. package/dist_ts/email/classes.accepted-email-spool.js +9 -1
  16. package/dist_ts/email/classes.email-domain.manager.d.ts +17 -1
  17. package/dist_ts/email/classes.email-domain.manager.js +105 -18
  18. package/dist_ts/email/classes.email-route-builder.js +5 -2
  19. package/dist_ts/email/classes.email-settings.manager.js +3 -3
  20. package/dist_ts/email/classes.mail-dns-sync.d.ts +27 -0
  21. package/dist_ts/email/classes.mail-dns-sync.js +196 -0
  22. package/dist_ts/email/classes.mail-egress-coordinator.d.ts +37 -0
  23. package/dist_ts/email/classes.mail-egress-coordinator.js +113 -0
  24. package/dist_ts/email/index.d.ts +2 -0
  25. package/dist_ts/email/index.js +3 -1
  26. package/dist_ts/opsserver/handlers/config.handler.js +4 -2
  27. package/dist_ts/opsserver/handlers/email-domain.handler.js +3 -1
  28. package/dist_ts/opsserver/handlers/email-ops.handler.d.ts +20 -3
  29. package/dist_ts/opsserver/handlers/email-ops.handler.js +141 -19
  30. package/dist_ts/opsserver/handlers/email-settings.handler.js +11 -6
  31. package/dist_ts/opsserver/handlers/remoteingress.handler.js +16 -7
  32. package/dist_ts/remoteingress/classes.remoteingress-manager.d.ts +33 -1
  33. package/dist_ts/remoteingress/classes.remoteingress-manager.js +94 -2
  34. package/dist_ts/remoteingress/classes.tunnel-manager.d.ts +18 -0
  35. package/dist_ts/remoteingress/classes.tunnel-manager.js +114 -12
  36. package/dist_ts/remoteingress/edge-derivation-routes.d.ts +10 -0
  37. package/dist_ts/remoteingress/edge-derivation-routes.js +28 -0
  38. package/dist_ts/remoteingress/index.d.ts +1 -0
  39. package/dist_ts/remoteingress/index.js +2 -1
  40. package/dist_ts_interfaces/data/email-domain.d.ts +11 -1
  41. package/dist_ts_interfaces/data/email-settings.d.ts +11 -0
  42. package/dist_ts_interfaces/data/remoteingress.d.ts +10 -0
  43. package/dist_ts_interfaces/data/remoteingress.js +5 -2
  44. package/dist_ts_interfaces/requests/email-domains.d.ts +5 -1
  45. package/dist_ts_interfaces/requests/email-ops.d.ts +4 -0
  46. package/dist_ts_interfaces/requests/remoteingress.d.ts +11 -0
  47. package/dist_ts_migrations/index.js +91 -9
  48. package/dist_ts_web/00_commitinfo_data.js +2 -2
  49. package/package.json +4 -4
  50. package/ts/00_commitinfo_data.ts +1 -1
  51. package/ts/classes.dcrouter.ts +41 -48
  52. package/ts/db/documents/classes.cached.email.ts +72 -1
  53. package/ts/db/documents/classes.email-domain.doc.ts +4 -0
  54. package/ts/db/documents/classes.email-server-settings.doc.ts +1 -1
  55. package/ts/db/documents/classes.remote-ingress-edge.doc.ts +9 -0
  56. package/ts/dns/classes.dns-server-runtime.ts +2 -0
  57. package/ts/email/classes.accepted-email-spool.ts +12 -1
  58. package/ts/email/classes.email-domain.manager.ts +123 -18
  59. package/ts/email/classes.email-route-builder.ts +4 -1
  60. package/ts/email/classes.email-settings.manager.ts +2 -2
  61. package/ts/email/classes.mail-dns-sync.ts +218 -0
  62. package/ts/email/classes.mail-egress-coordinator.ts +125 -0
  63. package/ts/email/index.ts +2 -0
  64. package/ts/opsserver/handlers/config.handler.ts +3 -1
  65. package/ts/opsserver/handlers/email-domain.handler.ts +2 -0
  66. package/ts/opsserver/handlers/email-ops.handler.ts +156 -18
  67. package/ts/opsserver/handlers/email-settings.handler.ts +10 -5
  68. package/ts/opsserver/handlers/remoteingress.handler.ts +15 -5
  69. package/ts/remoteingress/classes.remoteingress-manager.ts +111 -0
  70. package/ts/remoteingress/classes.tunnel-manager.ts +125 -10
  71. package/ts/remoteingress/edge-derivation-routes.ts +29 -0
  72. package/ts/remoteingress/index.ts +1 -0
  73. package/ts_web/00_commitinfo_data.ts +1 -1
@@ -5,7 +5,8 @@ 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
7
  import type { DnsManager } from '../dns/manager.dns.js';
8
- import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_interfaces/data/email-domain.js';
8
+ import type { IEmailDomain, IEmailDnsRecord, IEmailDomainRemoteIngress, TDnsRecordStatus } from '../../ts_interfaces/data/email-domain.js';
9
+ import type { IRemoteIngress } from '../../ts_interfaces/data/remoteingress.js';
9
10
  import { buildEmailDnsRecords } from './email-dns-records.js';
10
11
 
11
12
  /**
@@ -18,6 +19,7 @@ import { buildEmailDnsRecords } from './email-dns-records.js';
18
19
  export class EmailDomainManager {
19
20
  private dcRouter: any; // DcRouter — avoids circular import
20
21
  private baseEmailDomains: IEmailDomainConfig[] = [];
22
+ private dnsClient?: plugins.smartdns.dnsClientMod.Smartdns;
21
23
 
22
24
  constructor(dcRouterRef: any) {
23
25
  this.dcRouter = dcRouterRef;
@@ -45,7 +47,10 @@ export class EmailDomainManager {
45
47
  await this.syncManagedDomainsToRuntime();
46
48
  }
47
49
 
48
- public async stop(): Promise<void> {}
50
+ public async stop(): Promise<void> {
51
+ this.dnsClient?.destroy();
52
+ this.dnsClient = undefined;
53
+ }
49
54
 
50
55
  // ---------------------------------------------------------------------------
51
56
  // CRUD
@@ -93,6 +98,7 @@ export class EmailDomainManager {
93
98
  dkimKeySize?: number;
94
99
  rotateKeys?: boolean;
95
100
  rotationIntervalDays?: number;
101
+ remoteIngress?: IEmailDomainRemoteIngress;
96
102
  }): Promise<IEmailDomain> {
97
103
  // Resolve the linked DNS domain
98
104
  const domainDoc = await DomainDoc.findById(opts.linkedDomainId);
@@ -150,10 +156,12 @@ export class EmailDomainManager {
150
156
  dkim: 'unchecked',
151
157
  dmarc: 'unchecked',
152
158
  };
159
+ doc.remoteIngress = this.normalizeRemoteIngressPin(opts.remoteIngress, domainName);
153
160
  doc.createdAt = now;
154
161
  doc.updatedAt = now;
155
162
  await doc.save();
156
163
  await this.syncManagedDomainsToRuntime();
164
+ this.dcRouter.mailDnsSync?.requestSync(`email domain created: ${domainName}`);
157
165
 
158
166
  logger.log('info', `Email domain created: ${domainName}`);
159
167
  return this.docToInterface(doc);
@@ -165,6 +173,8 @@ export class EmailDomainManager {
165
173
  rotateKeys?: boolean;
166
174
  rotationIntervalDays?: number;
167
175
  rateLimits?: IEmailDomain['rateLimits'];
176
+ /** null clears the pin. */
177
+ remoteIngress?: IEmailDomainRemoteIngress | null;
168
178
  },
169
179
  ): Promise<void> {
170
180
  const doc = await EmailDomainDoc.findById(id);
@@ -173,9 +183,43 @@ export class EmailDomainManager {
173
183
  if (changes.rotateKeys !== undefined) doc.dkim.rotateKeys = changes.rotateKeys;
174
184
  if (changes.rotationIntervalDays !== undefined) doc.dkim.rotationIntervalDays = changes.rotationIntervalDays;
175
185
  if (changes.rateLimits !== undefined) doc.rateLimits = changes.rateLimits;
186
+ if (changes.remoteIngress !== undefined) {
187
+ doc.remoteIngress = changes.remoteIngress === null
188
+ ? undefined
189
+ : this.normalizeRemoteIngressPin(changes.remoteIngress, doc.domain);
190
+ }
176
191
  doc.updatedAt = new Date().toISOString();
177
192
  await doc.save();
178
193
  await this.syncManagedDomainsToRuntime();
194
+ this.dcRouter.mailDnsSync?.requestSync(`email domain updated: ${doc.domain}`);
195
+ }
196
+
197
+ /**
198
+ * Normalize a per-domain edge pin. Pins may reference edges that are not
199
+ * registered yet — that only warns, so pins can precede edge registration.
200
+ */
201
+ private normalizeRemoteIngressPin(
202
+ pin: IEmailDomainRemoteIngress | undefined,
203
+ domainName: string,
204
+ ): IEmailDomainRemoteIngress | undefined {
205
+ const edgeFilter = [...new Set((pin?.edgeFilter || []).map((entry) => `${entry}`.trim()).filter(Boolean))];
206
+ if (edgeFilter.length === 0) {
207
+ return undefined;
208
+ }
209
+ const manager = this.dcRouter.remoteIngressManager;
210
+ if (manager && manager.resolveEdgesByFilter(edgeFilter).length === 0) {
211
+ logger.log('warn', `Email domain ${domainName}: edge pin [${edgeFilter.join(', ')}] matches no registered enabled edge yet`);
212
+ }
213
+ return { edgeFilter };
214
+ }
215
+
216
+ /** Enabled edges pinned for this domain (empty when unpinned). */
217
+ private resolvePinnedEdges(doc: EmailDomainDoc): IRemoteIngress[] {
218
+ const edgeFilter = doc.remoteIngress?.edgeFilter;
219
+ if (!edgeFilter?.length) {
220
+ return [];
221
+ }
222
+ return this.dcRouter.remoteIngressManager?.resolveEdgesByFilter(edgeFilter) || [];
179
223
  }
180
224
 
181
225
  public async deleteEmailDomain(id: string): Promise<void> {
@@ -183,6 +227,7 @@ export class EmailDomainManager {
183
227
  if (!doc) throw new Error(`Email domain not found: ${id}`);
184
228
  await doc.delete();
185
229
  await this.syncManagedDomainsToRuntime();
230
+ this.dcRouter.mailDnsSync?.requestSync(`email domain deleted: ${doc.domain}`);
186
231
  logger.log('info', `Email domain deleted: ${doc.domain}`);
187
232
  }
188
233
 
@@ -199,7 +244,10 @@ export class EmailDomainManager {
199
244
 
200
245
  const domain = doc.domain;
201
246
  const selector = doc.dkim.selector;
202
- const hostname = this.emailHostname;
247
+ // Pinned domains get their own MX host whose A records are the pinned edges'
248
+ // public IPs; unpinned domains use the shared mail hostname.
249
+ const pinnedEdges = this.resolvePinnedEdges(doc);
250
+ const hostname = pinnedEdges.length > 0 ? `mx.${domain}` : this.emailHostname;
203
251
  let dkimValue = `v=DKIM1; h=sha256; k=rsa; p=${doc.dkim.publicKey || ''}`;
204
252
 
205
253
  if (this.dkimCreator) {
@@ -211,13 +259,24 @@ export class EmailDomainManager {
211
259
  }
212
260
  }
213
261
 
214
- return buildEmailDnsRecords({
262
+ const records = buildEmailDnsRecords({
215
263
  domain,
216
264
  hostname,
217
265
  selector,
218
266
  dkimValue,
219
267
  statuses: doc.dnsStatus,
220
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
+ }
277
+ }
278
+
279
+ return records;
221
280
  }
222
281
 
223
282
  // ---------------------------------------------------------------------------
@@ -280,7 +339,7 @@ export class EmailDomainManager {
280
339
 
281
340
  const domain = doc.domain;
282
341
  const selector = doc.dkim.selector;
283
- const resolver = new plugins.dns.promises.Resolver();
342
+ const dnsClient = this.getDnsClient();
284
343
 
285
344
  // MX check
286
345
  const requiredRecords = await this.getRequiredDnsRecords(id);
@@ -290,16 +349,26 @@ export class EmailDomainManager {
290
349
  const dkimRecord = requiredRecords.find((record) => record.name === `${selector}._domainkey.${domain}`);
291
350
  const dmarcRecord = requiredRecords.find((record) => record.name === `_dmarc.${domain}`);
292
351
 
293
- doc.dnsStatus.mx = await this.checkMx(resolver, domain, mxRecord?.value);
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;
361
+ }
362
+ }
294
363
 
295
364
  // SPF check
296
- doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, spfRecord?.value);
365
+ doc.dnsStatus.spf = await this.checkTxtRecord(dnsClient, domain, spfRecord?.value);
297
366
 
298
367
  // DKIM check
299
- doc.dnsStatus.dkim = await this.checkTxtRecord(resolver, `${selector}._domainkey.${domain}`, dkimRecord?.value);
368
+ doc.dnsStatus.dkim = await this.checkTxtRecord(dnsClient, `${selector}._domainkey.${domain}`, dkimRecord?.value);
300
369
 
301
370
  // DMARC check
302
- doc.dnsStatus.dmarc = await this.checkTxtRecord(resolver, `_dmarc.${domain}`, dmarcRecord?.value);
371
+ doc.dnsStatus.dmarc = await this.checkTxtRecord(dnsClient, `_dmarc.${domain}`, dmarcRecord?.value);
303
372
 
304
373
  doc.dnsStatus.lastCheckedAt = new Date().toISOString();
305
374
  doc.updatedAt = new Date().toISOString();
@@ -315,41 +384,72 @@ export class EmailDomainManager {
315
384
  return record.value.trim() === required.value.trim();
316
385
  }
317
386
 
387
+ /** Lazy shared smartdns client; the Rust bridge spawns on first non-system lookup. */
388
+ private getDnsClient(): plugins.smartdns.dnsClientMod.Smartdns {
389
+ if (!this.dnsClient) {
390
+ this.dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({});
391
+ }
392
+ return this.dnsClient;
393
+ }
394
+
395
+ /** 'pref exchange' normalized for comparison: numeric pref, lowercased exchange, no trailing dot. */
396
+ private normalizeMxValue(value: string): string {
397
+ const [pref, exchange = ''] = value.trim().split(/\s+/);
398
+ return `${Number(pref)} ${exchange.toLowerCase().replace(/\.$/, '')}`;
399
+ }
400
+
318
401
  private async checkMx(
319
- resolver: plugins.dns.promises.Resolver,
402
+ dnsClient: plugins.smartdns.dnsClientMod.Smartdns,
320
403
  domain: string,
321
404
  expectedValue?: string,
322
405
  ): Promise<TDnsRecordStatus> {
323
406
  try {
324
- const records = await resolver.resolveMx(domain);
407
+ const records = await dnsClient.getRecords(domain, 'MX');
325
408
  if (!records || records.length === 0) {
326
409
  return 'missing';
327
410
  }
328
411
  if (!expectedValue) {
329
412
  return 'valid';
330
413
  }
331
- const found = records.some((record) => `${record.priority} ${record.exchange}`.trim() === expectedValue.trim());
414
+ const expected = this.normalizeMxValue(expectedValue);
415
+ const found = records.some((record) => this.normalizeMxValue(record.value) === expected);
332
416
  return found ? 'valid' : 'invalid';
333
417
  } catch {
334
418
  return 'missing';
335
419
  }
336
420
  }
337
421
 
422
+ private async checkARecords(
423
+ dnsClient: plugins.smartdns.dnsClientMod.Smartdns,
424
+ name: string,
425
+ expectedIps: string[],
426
+ ): Promise<TDnsRecordStatus> {
427
+ try {
428
+ const records = await dnsClient.getRecordsA(name);
429
+ if (!records || records.length === 0) {
430
+ return 'missing';
431
+ }
432
+ const resolved = new Set(records.map((record) => record.value));
433
+ return expectedIps.every((ip) => resolved.has(ip)) ? 'valid' : 'invalid';
434
+ } catch {
435
+ return 'missing';
436
+ }
437
+ }
438
+
338
439
  private async checkTxtRecord(
339
- resolver: plugins.dns.promises.Resolver,
440
+ dnsClient: plugins.smartdns.dnsClientMod.Smartdns,
340
441
  name: string,
341
442
  expectedValue?: string,
342
443
  ): Promise<TDnsRecordStatus> {
343
444
  try {
344
- const records = await resolver.resolveTxt(name);
345
- const flat = records.map((r) => r.join(''));
346
- if (flat.length === 0) {
445
+ const records = await dnsClient.getRecordsTxt(name);
446
+ if (!records || records.length === 0) {
347
447
  return 'missing';
348
448
  }
349
449
  if (!expectedValue) {
350
450
  return 'valid';
351
451
  }
352
- const found = flat.some((record) => record.trim() === expectedValue.trim());
452
+ const found = records.some((record) => record.value.trim() === expectedValue.trim());
353
453
  return found ? 'valid' : 'invalid';
354
454
  } catch {
355
455
  return 'missing';
@@ -368,6 +468,7 @@ export class EmailDomainManager {
368
468
  subdomain: doc.subdomain,
369
469
  dkim: doc.dkim,
370
470
  rateLimits: doc.rateLimits,
471
+ remoteIngress: doc.remoteIngress,
371
472
  dnsStatus: doc.dnsStatus,
372
473
  createdAt: doc.createdAt,
373
474
  updatedAt: doc.updatedAt,
@@ -400,7 +501,11 @@ export class EmailDomainManager {
400
501
 
401
502
  managedConfigs.push({
402
503
  domain: doc.domain,
403
- dnsMode: linkedDomain.source === 'dcrouter' ? 'internal-dns' : 'external-dns',
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
+ dnsMode: 'external-dns',
404
509
  dkim: {
405
510
  selector: doc.dkim.selector,
406
511
  keySize: doc.dkim.keySize,
@@ -3,6 +3,7 @@ import { logger } from '../logger.js';
3
3
  import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
4
4
  import type { IRoute } from '../../ts_interfaces/data/route-management.js';
5
5
  import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
6
+ import { REMOTE_INGRESS_MAIL_TAG } from '../../ts_interfaces/data/remoteingress.js';
6
7
  import type { DcRouter } from '../classes.dcrouter.js';
7
8
 
8
9
  type TInboundProxyProtocolPolicy = NonNullable<plugins.smartproxy.IRouteMatch['inboundProxyProtocol']>;
@@ -99,7 +100,9 @@ export class EmailRouteBuilder {
99
100
  };
100
101
 
101
102
  if (this.dcRouterRef.isRemoteIngressHubEnabled()) {
102
- routeConfig.remoteIngress = { enabled: true };
103
+ // Only mail-tagged edges bind SMTP ports — other edges may sit on hosts
104
+ // where 25/465/587 belong to a different mail system.
105
+ routeConfig.remoteIngress = { enabled: true, edgeFilter: [REMOTE_INGRESS_MAIL_TAG] };
103
106
  const inboundProxyProtocol = this.getRemoteIngressEmailInboundProxyPolicy(port);
104
107
  if (inboundProxyProtocol) {
105
108
  routeConfig.match.inboundProxyProtocol = inboundProxyProtocol;
@@ -22,7 +22,7 @@ function hasOwn(objectArg: object, keyArg: string): boolean {
22
22
  export class EmailSettingsManager {
23
23
  private cachedEmailConfig?: IUnifiedEmailServerOptions;
24
24
  private cachedEmailPortConfig?: IEmailPortConfig;
25
- private outboundMode: TEmailOutboundMode = 'direct';
25
+ private outboundMode: TEmailOutboundMode = 'remoteIngress';
26
26
  private enabled = false;
27
27
  private updatedAt = 0;
28
28
  private updatedBy = 'default';
@@ -140,7 +140,7 @@ export class EmailSettingsManager {
140
140
  }
141
141
 
142
142
  private normalizeOutboundMode(mode: TEmailOutboundMode | undefined): TEmailOutboundMode {
143
- if (!mode) return 'direct';
143
+ if (!mode) return 'remoteIngress';
144
144
  if (mode !== 'direct' && mode !== 'remoteIngress') {
145
145
  throw new Error('Invalid email outbound mode');
146
146
  }
@@ -0,0 +1,218 @@
1
+ import { logger } from '../logger.js';
2
+ import { DnsRecordDoc } from '../db/documents/classes.dns-record.doc.js';
3
+ import { DomainDoc } from '../db/documents/classes.domain.doc.js';
4
+ import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
5
+ import type { DcRouter } from '../classes.dcrouter.js';
6
+ import type { IRemoteIngress } from '../../ts_interfaces/data/remoteingress.js';
7
+
8
+ export const MAIL_DNS_SYNC_CREATED_BY = 'mail-edge-sync';
9
+
10
+ interface IDesiredMailDnsRecord {
11
+ name: string;
12
+ type: 'A' | 'AAAA';
13
+ value: string;
14
+ }
15
+
16
+ /**
17
+ * Reconciles the mail-facing A/AAAA records that make edges — never the hub —
18
+ * the public identity of the mail platform:
19
+ * (a) shared mail hostname → publicIp/publicIpV6 of every enabled mail edge,
20
+ * (b) each edge's mailHostname → that edge's IPs (FCrDNS for outbound EHLO),
21
+ * (c) mx.<domain> → the pinned edges' IPs for every pinned email domain.
22
+ * Records are DB-backed DnsRecordDoc rows owned via createdBy 'mail-edge-sync';
23
+ * provider-hosted zones (e.g. Cloudflare) are written through DnsManager's
24
+ * provider path, dcrouter-authoritative zones serve immediately.
25
+ */
26
+ export class MailDnsSync {
27
+ private syncInFlight?: Promise<void>;
28
+ private resyncQueued = false;
29
+
30
+ constructor(private dcRouterRef: DcRouter) {}
31
+
32
+ /** Fire-and-forget trigger for CRUD hooks; failures are logged, not thrown. */
33
+ public requestSync(reason: string): void {
34
+ this.sync(reason).catch((err: unknown) => {
35
+ logger.log('error', `MailDnsSync failed (${reason}): ${(err as Error).message}`);
36
+ });
37
+ }
38
+
39
+ /** Coalescing entry point: concurrent triggers fold into one follow-up pass. */
40
+ public async sync(reason: string): Promise<void> {
41
+ if (this.syncInFlight) {
42
+ this.resyncQueued = true;
43
+ return this.syncInFlight;
44
+ }
45
+ this.syncInFlight = this.reconcile(reason).finally(() => {
46
+ this.syncInFlight = undefined;
47
+ if (this.resyncQueued) {
48
+ this.resyncQueued = false;
49
+ this.requestSync(`${reason} (coalesced)`);
50
+ }
51
+ });
52
+ return this.syncInFlight;
53
+ }
54
+
55
+ private async reconcile(reason: string): Promise<void> {
56
+ if (this.dcRouterRef.options.dbConfig?.enabled === false) {
57
+ return;
58
+ }
59
+ const dnsManager = this.dcRouterRef.dnsManager;
60
+ const remoteIngressManager = this.dcRouterRef.remoteIngressManager;
61
+ if (!dnsManager || !remoteIngressManager) {
62
+ // Without both managers the edge registry / record store is not
63
+ // authoritative — skip entirely rather than delete managed records.
64
+ return;
65
+ }
66
+
67
+ const desired = await this.computeDesiredRecords();
68
+ const zones = await DomainDoc.findAll();
69
+
70
+ // Place each desired record in its zone (longest-suffix match). Records
71
+ // without a managed zone are reported — the operator must onboard the zone.
72
+ const placed = new Map<string, { domainId: string; record: IDesiredMailDnsRecord }>();
73
+ const unplacedNames = new Set<string>();
74
+ for (const record of desired) {
75
+ const zone = this.findZoneForName(record.name, zones);
76
+ if (!zone) {
77
+ unplacedNames.add(record.name);
78
+ continue;
79
+ }
80
+ placed.set(`${zone.id}|${record.name}|${record.type}|${record.value}`, {
81
+ domainId: zone.id,
82
+ record,
83
+ });
84
+ }
85
+ for (const name of unplacedNames) {
86
+ logger.log('warn', `MailDnsSync: no managed DNS zone covers ${name} — record skipped`);
87
+ }
88
+
89
+ const existing = (await DnsRecordDoc.findAll())
90
+ .filter((record) => record.createdBy === MAIL_DNS_SYNC_CREATED_BY);
91
+
92
+ let created = 0;
93
+ let deleted = 0;
94
+
95
+ for (const record of existing) {
96
+ const key = `${record.domainId}|${record.name}|${record.type}|${record.value}`;
97
+ if (placed.has(key)) {
98
+ placed.delete(key); // already present — nothing to create
99
+ continue;
100
+ }
101
+ const result = await dnsManager.deleteRecord(record.id);
102
+ if (result.success) {
103
+ deleted++;
104
+ } else {
105
+ logger.log('warn', `MailDnsSync: failed to delete stale ${record.type} ${record.name} → ${record.value}: ${result.message}`);
106
+ }
107
+ }
108
+
109
+ for (const { domainId, record } of placed.values()) {
110
+ const result = await dnsManager.createRecord({
111
+ domainId,
112
+ name: record.name,
113
+ type: record.type,
114
+ value: record.value,
115
+ // Short TTL: edge IPs are burnable identities and failover heals via DNS.
116
+ ttl: 300,
117
+ createdBy: MAIL_DNS_SYNC_CREATED_BY,
118
+ });
119
+ if (result.success) {
120
+ created++;
121
+ } else {
122
+ logger.log('warn', `MailDnsSync: failed to create ${record.type} ${record.name} → ${record.value}: ${result.message}`);
123
+ }
124
+ }
125
+
126
+ if (created > 0 || deleted > 0) {
127
+ logger.log('info', `MailDnsSync (${reason}): ${created} record(s) created, ${deleted} deleted`);
128
+ }
129
+ }
130
+
131
+ private async computeDesiredRecords(): Promise<IDesiredMailDnsRecord[]> {
132
+ const remoteIngressManager = this.dcRouterRef.remoteIngressManager!;
133
+ const desired: IDesiredMailDnsRecord[] = [];
134
+
135
+ const pushEdgeIps = (name: string | undefined, edge: IRemoteIngress) => {
136
+ if (!name) return;
137
+ const normalizedName = name.toLowerCase();
138
+ if (edge.publicIp) {
139
+ desired.push({ name: normalizedName, type: 'A', value: edge.publicIp });
140
+ }
141
+ if (edge.publicIpV6) {
142
+ desired.push({ name: normalizedName, type: 'AAAA', value: edge.publicIpV6 });
143
+ }
144
+ };
145
+
146
+ // (a) shared mail hostname → every enabled mail edge
147
+ const sharedHostname = this.getSharedMailHostname();
148
+ const mailEdges = remoteIngressManager.getMailEdges();
149
+ for (const edge of mailEdges) {
150
+ pushEdgeIps(sharedHostname, edge);
151
+ }
152
+
153
+ // (b) per-edge FCrDNS hostname → that edge only
154
+ for (const edge of remoteIngressManager.getAllEdges()) {
155
+ if (!edge.enabled) continue;
156
+ pushEdgeIps(edge.mailHostname, edge);
157
+ }
158
+
159
+ // (c) mx.<domain> → pinned edges for every pinned email domain
160
+ const emailDomainDocs = await EmailDomainDoc.findAll();
161
+ for (const doc of emailDomainDocs) {
162
+ const edgeFilter = doc.remoteIngress?.edgeFilter;
163
+ if (!edgeFilter?.length) continue;
164
+ const pinnedEdges = remoteIngressManager.resolveEdgesByFilter(edgeFilter);
165
+ if (pinnedEdges.length === 0) {
166
+ logger.log('warn', `MailDnsSync: email domain ${doc.domain} pin [${edgeFilter.join(', ')}] matches no enabled edge — mx.${doc.domain} not published`);
167
+ continue;
168
+ }
169
+ for (const edge of pinnedEdges) {
170
+ pushEdgeIps(`mx.${doc.domain}`, edge);
171
+ }
172
+ }
173
+
174
+ // Hard hub-hiding guard: a hub IP must never become a mail-facing answer.
175
+ const hubIps = new Set(
176
+ [this.dcRouterRef.options.publicIp, this.dcRouterRef.detectedPublicIp].filter(Boolean),
177
+ );
178
+ const guarded = desired.filter((record) => {
179
+ if (hubIps.has(record.value)) {
180
+ logger.log('error', `MailDnsSync: refusing to publish ${record.type} ${record.name} → ${record.value} — value is the hub public IP`);
181
+ return false;
182
+ }
183
+ return true;
184
+ });
185
+
186
+ // Dedupe (several edges may share a tag and a pin may overlap the mail pool)
187
+ const seen = new Set<string>();
188
+ return guarded.filter((record) => {
189
+ const key = `${record.name}|${record.type}|${record.value}`;
190
+ if (seen.has(key)) return false;
191
+ seen.add(key);
192
+ return true;
193
+ });
194
+ }
195
+
196
+ private getSharedMailHostname(): string | undefined {
197
+ const hostname = this.dcRouterRef.options.emailConfig?.hostname
198
+ || this.dcRouterRef.options.tls?.domain;
199
+ if (!hostname || hostname === 'localhost') {
200
+ return undefined;
201
+ }
202
+ return hostname;
203
+ }
204
+
205
+ /** Longest-suffix zone match: 'mx.gated.one' → zone 'gated.one'. */
206
+ private findZoneForName(name: string, zones: DomainDoc[]): DomainDoc | undefined {
207
+ let best: DomainDoc | undefined;
208
+ for (const zone of zones) {
209
+ const zoneName = zone.name.toLowerCase();
210
+ if (name === zoneName || name.endsWith(`.${zoneName}`)) {
211
+ if (!best || zoneName.length > best.name.length) {
212
+ best = zone;
213
+ }
214
+ }
215
+ }
216
+ return best;
217
+ }
218
+ }
@@ -0,0 +1,125 @@
1
+ import type {
2
+ IOutboundConnectionProxy,
3
+ IOutboundConnectionProxyContext,
4
+ } from '@push.rocks/smartmta';
5
+ import { logger } from '../logger.js';
6
+ import { REMOTE_INGRESS_MAIL_TAG } from '../../ts_interfaces/data/remoteingress.js';
7
+ import type { IEmailOutboundEgressStatus, TEmailOutboundMode } from '../../ts_interfaces/data/email-settings.js';
8
+ import type { DcRouter } from '../classes.dcrouter.js';
9
+
10
+ /**
11
+ * Routes outbound SMTP deliveries over RemoteIngress edges: resolves the
12
+ * outbound mode per attempt (no silent direct fallback), maps sender-domain
13
+ * pins to an edgeFilter, opens the per-delivery QUIC egress proxy, and
14
+ * presents the selected edge's FCrDNS hostname as the EHLO identity.
15
+ */
16
+ export class MailEgressCoordinator {
17
+ constructor(private dcRouterRef: DcRouter) {}
18
+
19
+ /**
20
+ * smartmta connectionProxyProvider. Wired unconditionally so the outbound
21
+ * mode is resolved per delivery attempt: a failed/late settings manager
22
+ * defers mail loudly instead of silently degrading to direct-from-hub
23
+ * egress, and mode changes take effect without an email server restart.
24
+ */
25
+ public async provideConnectionProxy(
26
+ context: IOutboundConnectionProxyContext,
27
+ ): Promise<IOutboundConnectionProxy | null> {
28
+ const mode = this.resolveOutboundModeStrict();
29
+ if (mode === 'direct') {
30
+ return null;
31
+ }
32
+ return await this.createRemoteIngressConnectionProxy(context);
33
+ }
34
+
35
+ /**
36
+ * Resolve the effective email outbound mode for a delivery attempt.
37
+ * Throws when the settings manager is unavailable — deferring the delivery —
38
+ * because guessing here is what silently degraded outbound to direct egress.
39
+ */
40
+ public resolveOutboundModeStrict(): TEmailOutboundMode {
41
+ if (this.dcRouterRef.emailSettingsManager) {
42
+ return this.dcRouterRef.emailSettingsManager.getOutboundMode();
43
+ }
44
+ if (this.dcRouterRef.options.emailOutboundMode) {
45
+ return this.dcRouterRef.options.emailOutboundMode;
46
+ }
47
+ throw new Error('EmailSettingsManager unavailable: refusing to resolve email outbound mode implicitly');
48
+ }
49
+
50
+ /**
51
+ * Readiness of the RemoteIngress outbound mail egress path (mail-tagged edges).
52
+ */
53
+ public getOutboundEgressStatus(): IEmailOutboundEgressStatus {
54
+ const tunnelManager = this.dcRouterRef.tunnelManager;
55
+ if (!tunnelManager) {
56
+ return { ready: false, eligibleEdgeCount: 0, reason: 'RemoteIngress hub is not running' };
57
+ }
58
+ return tunnelManager.getEgressReadiness([REMOTE_INGRESS_MAIL_TAG]);
59
+ }
60
+
61
+ /**
62
+ * Post-start preflight: with remoteIngress outbound (the default), mail
63
+ * defers until a QUIC mail egress edge connects. Surface that loudly
64
+ * instead of silently queueing.
65
+ */
66
+ public logEgressPreflight(): void {
67
+ try {
68
+ if (this.resolveOutboundModeStrict() === 'remoteIngress') {
69
+ const egressStatus = this.getOutboundEgressStatus();
70
+ if (!egressStatus.ready) {
71
+ logger.log('warn', `Outbound mail egress is NOT ready (${egressStatus.reason}); outbound mail will defer until a QUIC mail egress edge connects`);
72
+ }
73
+ }
74
+ } catch (error: unknown) {
75
+ logger.log('error', `Outbound mail mode could not be resolved: ${(error as Error).message}; outbound deliveries will defer until EmailSettingsManager is available`);
76
+ }
77
+ }
78
+
79
+ private async createRemoteIngressConnectionProxy(
80
+ context: IOutboundConnectionProxyContext,
81
+ ): Promise<IOutboundConnectionProxy> {
82
+ const tunnelManager = this.dcRouterRef.tunnelManager;
83
+ if (!tunnelManager) {
84
+ throw new Error('RemoteIngress outbound SMTP egress is enabled, but the hub is not running');
85
+ }
86
+
87
+ // Per-domain pinning: a sender domain with a configured edgeFilter uses only
88
+ // matching edges; everything else uses the mail-tagged edge pool.
89
+ let edgeFilter: string[] = [REMOTE_INGRESS_MAIL_TAG];
90
+ if (context.senderDomain && this.dcRouterRef.emailDomainManager) {
91
+ const domain = await this.dcRouterRef.emailDomainManager.getByDomain(context.senderDomain);
92
+ if (domain?.remoteIngress?.edgeFilter?.length) {
93
+ edgeFilter = domain.remoteIngress.edgeFilter;
94
+ }
95
+ }
96
+
97
+ const connectionTimeoutMs = this.dcRouterRef.options.emailConfig?.outbound?.connectionTimeout ?? 30_000;
98
+ const proxy = await tunnelManager.startEgressTcpProxy({
99
+ logicalHost: context.host,
100
+ port: context.port,
101
+ serverFirst: !context.secure,
102
+ connectTimeoutMs: connectionTimeoutMs,
103
+ acceptTimeoutMs: connectionTimeoutMs,
104
+ edgeFilter,
105
+ });
106
+ let closed = false;
107
+ return {
108
+ connectHost: proxy.listenHost,
109
+ connectPort: proxy.listenPort,
110
+ poolKey: `remoteIngress:${proxy.edgeId}:${context.host}:${context.port}`,
111
+ ehloHostname: this.dcRouterRef.remoteIngressManager?.getEdge(proxy.edgeId)?.mailHostname,
112
+ close: async () => {
113
+ if (closed) {
114
+ return;
115
+ }
116
+ closed = true;
117
+ try {
118
+ await tunnelManager.stopEgressTcpProxy(proxy.proxyId);
119
+ } catch (err: unknown) {
120
+ logger.log('warn', `Failed to stop RemoteIngress egress proxy ${proxy.proxyId}: ${(err as Error).message}`);
121
+ }
122
+ },
123
+ };
124
+ }
125
+ }
package/ts/email/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export * from './classes.accepted-email-spool.js';
2
2
  export * from './classes.email-domain.manager.js';
3
3
  export * from './classes.email-route-builder.js';
4
+ export * from './classes.mail-dns-sync.js';
5
+ export * from './classes.mail-egress-coordinator.js';
4
6
  export * from './classes.email-settings.manager.js';
5
7
  export * from './classes.smartmta-storage-manager.js';
6
8
  export * from './classes.workapp-mail-manager.js';