@serve.zone/dcrouter 15.10.0 → 16.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) 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 +40 -44
  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 +9 -2
  15. package/dist_ts/email/classes.accepted-email-spool.js +53 -2
  16. package/dist_ts/email/classes.email-domain.manager.d.ts +17 -1
  17. package/dist_ts/email/classes.email-domain.manager.js +100 -17
  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/classes.workapp-mail-manager.d.ts +14 -1
  25. package/dist_ts/email/classes.workapp-mail-manager.js +138 -1
  26. package/dist_ts/email/index.d.ts +2 -0
  27. package/dist_ts/email/index.js +3 -1
  28. package/dist_ts/opsserver/handlers/config.handler.js +4 -2
  29. package/dist_ts/opsserver/handlers/email-domain.handler.js +3 -1
  30. package/dist_ts/opsserver/handlers/email-ops.handler.d.ts +20 -3
  31. package/dist_ts/opsserver/handlers/email-ops.handler.js +141 -19
  32. package/dist_ts/opsserver/handlers/email-settings.handler.js +11 -6
  33. package/dist_ts/opsserver/handlers/gatewayclient.handler.d.ts +1 -0
  34. package/dist_ts/opsserver/handlers/gatewayclient.handler.js +30 -1
  35. package/dist_ts/opsserver/handlers/remoteingress.handler.js +16 -7
  36. package/dist_ts/remoteingress/classes.remoteingress-manager.d.ts +33 -1
  37. package/dist_ts/remoteingress/classes.remoteingress-manager.js +94 -2
  38. package/dist_ts/remoteingress/classes.tunnel-manager.d.ts +18 -0
  39. package/dist_ts/remoteingress/classes.tunnel-manager.js +114 -12
  40. package/dist_ts/remoteingress/edge-derivation-routes.d.ts +10 -0
  41. package/dist_ts/remoteingress/edge-derivation-routes.js +28 -0
  42. package/dist_ts/remoteingress/index.d.ts +1 -0
  43. package/dist_ts/remoteingress/index.js +2 -1
  44. package/dist_ts_interfaces/data/email-domain.d.ts +11 -1
  45. package/dist_ts_interfaces/data/email-settings.d.ts +11 -0
  46. package/dist_ts_interfaces/data/remoteingress.d.ts +10 -0
  47. package/dist_ts_interfaces/data/remoteingress.js +5 -2
  48. package/dist_ts_interfaces/requests/email-domains.d.ts +5 -1
  49. package/dist_ts_interfaces/requests/email-ops.d.ts +4 -0
  50. package/dist_ts_interfaces/requests/remoteingress.d.ts +11 -0
  51. package/dist_ts_migrations/index.js +91 -9
  52. package/dist_ts_web/00_commitinfo_data.js +2 -2
  53. package/package.json +5 -5
  54. package/readme.md +1 -1
  55. package/ts/00_commitinfo_data.ts +1 -1
  56. package/ts/classes.dcrouter.ts +42 -49
  57. package/ts/db/documents/classes.cached.email.ts +72 -1
  58. package/ts/db/documents/classes.email-domain.doc.ts +4 -0
  59. package/ts/db/documents/classes.email-server-settings.doc.ts +1 -1
  60. package/ts/db/documents/classes.remote-ingress-edge.doc.ts +9 -0
  61. package/ts/dns/classes.dns-server-runtime.ts +2 -0
  62. package/ts/email/classes.accepted-email-spool.ts +80 -4
  63. package/ts/email/classes.email-domain.manager.ts +118 -17
  64. package/ts/email/classes.email-route-builder.ts +4 -1
  65. package/ts/email/classes.email-settings.manager.ts +2 -2
  66. package/ts/email/classes.mail-dns-sync.ts +218 -0
  67. package/ts/email/classes.mail-egress-coordinator.ts +125 -0
  68. package/ts/email/classes.workapp-mail-manager.ts +194 -1
  69. package/ts/email/index.ts +2 -0
  70. package/ts/opsserver/handlers/config.handler.ts +3 -1
  71. package/ts/opsserver/handlers/email-domain.handler.ts +2 -0
  72. package/ts/opsserver/handlers/email-ops.handler.ts +156 -18
  73. package/ts/opsserver/handlers/email-settings.handler.ts +10 -5
  74. package/ts/opsserver/handlers/gatewayclient.handler.ts +44 -0
  75. package/ts/opsserver/handlers/remoteingress.handler.ts +15 -5
  76. package/ts/readme.md +1 -1
  77. package/ts/remoteingress/classes.remoteingress-manager.ts +111 -0
  78. package/ts/remoteingress/classes.tunnel-manager.ts +125 -10
  79. package/ts/remoteingress/edge-derivation-routes.ts +29 -0
  80. package/ts/remoteingress/index.ts +1 -0
  81. package/ts_web/00_commitinfo_data.ts +1 -1
@@ -8,6 +8,12 @@ const TTL = plugins.smartdata.smartdataTtlValues;
8
8
  */
9
9
  export type TCachedEmailStatus = 'pending' | 'processing' | 'queued' | 'delivered' | 'failed' | 'deferred';
10
10
 
11
+ /**
12
+ * Direction of an accepted email: 'inbound' = received from a remote peer,
13
+ * 'outbound' = locally submitted for delivery.
14
+ */
15
+ export type TCachedEmailDirection = 'inbound' | 'outbound';
16
+
11
17
  /**
12
18
  * Helper to get the smartdata database instance
13
19
  */
@@ -112,6 +118,24 @@ export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<Cache
112
118
  @plugins.smartdata.svDb()
113
119
  public senderDomain!: string;
114
120
 
121
+ /**
122
+ * Direction of the accepted message (inbound receipt vs local submission)
123
+ */
124
+ @plugins.smartdata.svDb()
125
+ public direction: TCachedEmailDirection = 'outbound';
126
+
127
+ /**
128
+ * Lowercased recipient domains (for querying/filtering)
129
+ */
130
+ @plugins.smartdata.svDb()
131
+ public recipientDomains: string[] = [];
132
+
133
+ /**
134
+ * Epoch ms when the message was accepted (for newest-first listings)
135
+ */
136
+ @plugins.smartdata.svDb()
137
+ public acceptedAt: number = 0;
138
+
115
139
  /**
116
140
  * Priority level (higher = more important)
117
141
  */
@@ -183,7 +207,11 @@ export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<Cache
183
207
  }, limit);
184
208
  }
185
209
 
186
- private static async findLimited(filter: Record<string, any>, limit: number): Promise<CachedEmail[]> {
210
+ private static async findLimited(
211
+ filter: Record<string, any>,
212
+ limit: number,
213
+ sort?: Record<string, 1 | -1>,
214
+ ): Promise<CachedEmail[]> {
187
215
  const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 25;
188
216
  if (safeLimit === 0) {
189
217
  return [];
@@ -195,6 +223,9 @@ export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<Cache
195
223
  filter,
196
224
  CachedEmail as unknown as typeof plugins.smartdata.SmartDataDbDoc,
197
225
  );
226
+ if (sort) {
227
+ cursor.mongodbCursor.sort(sort);
228
+ }
198
229
  cursor.mongodbCursor.limit(safeLimit);
199
230
  try {
200
231
  return await cursor.toArray();
@@ -203,6 +234,32 @@ export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<Cache
203
234
  }
204
235
  }
205
236
 
237
+ /**
238
+ * Find the most recently accepted emails, newest first.
239
+ */
240
+ public static async findRecent(
241
+ limit = 200,
242
+ filter: { direction?: TCachedEmailDirection; recipientDomain?: string } = {},
243
+ ): Promise<CachedEmail[]> {
244
+ const query: Record<string, any> = {};
245
+ if (filter.direction) {
246
+ query.direction = filter.direction;
247
+ }
248
+ if (filter.recipientDomain) {
249
+ query.recipientDomains = filter.recipientDomain.toLowerCase();
250
+ }
251
+ return await CachedEmail.findLimited(query, limit, { acceptedAt: -1 });
252
+ }
253
+
254
+ /**
255
+ * Find emails addressed to a recipient domain
256
+ */
257
+ public static async findByRecipientDomain(domain: string): Promise<CachedEmail[]> {
258
+ return await CachedEmail.getInstances({
259
+ recipientDomains: { $all: [domain.toLowerCase()] },
260
+ });
261
+ }
262
+
206
263
  /**
207
264
  * Find emails by sender domain
208
265
  */
@@ -254,4 +311,18 @@ export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<Cache
254
311
  }
255
312
  }
256
313
  }
314
+
315
+ /**
316
+ * Derive recipient domains from the recipient address list
317
+ */
318
+ public updateRecipientDomains(): void {
319
+ const domains = new Set<string>();
320
+ for (const address of this.to || []) {
321
+ const match = address.match(/@([^>]+)>?$/);
322
+ if (match) {
323
+ domains.add(match[1].toLowerCase());
324
+ }
325
+ }
326
+ this.recipientDomains = [...domains];
327
+ }
257
328
  }
@@ -4,6 +4,7 @@ import type {
4
4
  IEmailDomainDkim,
5
5
  IEmailDomainRateLimits,
6
6
  IEmailDomainDnsStatus,
7
+ IEmailDomainRemoteIngress,
7
8
  } from '../../../ts_interfaces/data/email-domain.js';
8
9
 
9
10
  const getDb = () => DcRouterDb.getInstance().getDb();
@@ -29,6 +30,9 @@ export class EmailDomainDoc extends plugins.smartdata.SmartDataDbDoc<EmailDomain
29
30
  @plugins.smartdata.svDb()
30
31
  public rateLimits?: IEmailDomainRateLimits;
31
32
 
33
+ @plugins.smartdata.svDb()
34
+ public remoteIngress?: IEmailDomainRemoteIngress;
35
+
32
36
  @plugins.smartdata.svDb()
33
37
  public dnsStatus!: IEmailDomainDnsStatus;
34
38
 
@@ -18,7 +18,7 @@ export class EmailServerSettingsDoc extends plugins.smartdata.SmartDataDbDoc<Ema
18
18
  public emailConfig?: IUnifiedEmailServerOptions;
19
19
 
20
20
  @plugins.smartdata.svDb()
21
- public outboundMode: TEmailOutboundMode = 'direct';
21
+ public outboundMode: TEmailOutboundMode = 'remoteIngress';
22
22
 
23
23
  @plugins.smartdata.svDb()
24
24
  public emailPortConfig?: IEmailPortConfig;
@@ -37,6 +37,15 @@ export class RemoteIngressEdgeDoc extends plugins.smartdata.SmartDataDbDoc<Remot
37
37
  @plugins.smartdata.svDb()
38
38
  public tags!: string[];
39
39
 
40
+ @plugins.smartdata.svDb()
41
+ public publicIp?: string;
42
+
43
+ @plugins.smartdata.svDb()
44
+ public publicIpV6?: string;
45
+
46
+ @plugins.smartdata.svDb()
47
+ public mailHostname?: string;
48
+
40
49
  @plugins.smartdata.svDb()
41
50
  public createdAt!: number;
42
51
 
@@ -446,6 +446,7 @@ export class DnsServerRuntime {
446
446
  // Use explicitly configured public IP
447
447
  publicIp = options.publicIp;
448
448
  this.dcRouterRef.detectedPublicIp = publicIp;
449
+ this.dcRouterRef.remoteIngressManager?.setHubPublicIps([options.publicIp, publicIp]);
449
450
  logger.log('info', `Using configured public IP for nameserver A records: ${publicIp}`);
450
451
  } else {
451
452
  // Auto-discover public IP using smartnetwork
@@ -457,6 +458,7 @@ export class DnsServerRuntime {
457
458
  if (publicIps.v4) {
458
459
  publicIp = publicIps.v4;
459
460
  this.dcRouterRef.detectedPublicIp = publicIp;
461
+ this.dcRouterRef.remoteIngressManager?.setHubPublicIps([options.publicIp, publicIp]);
460
462
  logger.log('info', `Auto-discovered public IPv4: ${publicIp}`);
461
463
  } else {
462
464
  logger.log('warn', 'Could not auto-discover public IPv4 address');
@@ -1,6 +1,7 @@
1
1
  import * as plugins from '../plugins.js';
2
2
  import { logger } from '../logger.js';
3
3
  import { CachedEmail } from '../db/index.js';
4
+ import type { TCachedEmailDirection } from '../db/documents/classes.cached.email.js';
4
5
  import type {
5
6
  Email,
6
7
  IExtendedSmtpSession,
@@ -28,6 +29,10 @@ export type TSmartMtaQueueItemLike = {
28
29
  lastError?: string;
29
30
  };
30
31
 
32
+ type TSmartMtaQueueReader = UnifiedEmailServer & {
33
+ getQueueItems?: () => TSmartMtaQueueItemLike[];
34
+ };
35
+
31
36
  type TStoredCachedEmailEnvelopeAddress = {
32
37
  address: string;
33
38
  args?: Record<string, string>;
@@ -48,6 +53,12 @@ type TStoredCachedEmailSession = {
48
53
 
49
54
  type TStoredCachedEmailRouteData = {
50
55
  session?: TStoredCachedEmailSession;
56
+ smartMta?: {
57
+ status?: 'queued' | 'deferred' | 'delivered' | 'failed';
58
+ nextAttempt?: string;
59
+ lastError?: string;
60
+ updatedAt?: string;
61
+ };
51
62
  };
52
63
 
53
64
  export interface IAcceptRawEmailOptions {
@@ -60,6 +71,8 @@ export interface IAcceptRawEmailOptions {
60
71
  messageId?: string;
61
72
  subject?: string;
62
73
  processAfterAccept?: boolean;
74
+ /** Explicit direction override for programmatic submitters. Defaults to the session-authenticated rule. */
75
+ direction?: TCachedEmailDirection;
63
76
  }
64
77
 
65
78
  /**
@@ -108,6 +121,8 @@ export class AcceptedEmailSpool {
108
121
  cachedEmail.rawContent = this.setDcRouterCacheIdHeader(rawMessage.toString('utf8'), cachedEmail.id);
109
122
  cachedEmail.status = 'pending';
110
123
  cachedEmail.nextAttempt = new Date();
124
+ cachedEmail.direction = session.authenticated ? 'outbound' : 'inbound';
125
+ cachedEmail.acceptedAt = Date.now();
111
126
  cachedEmail.routeData = JSON.stringify({
112
127
  acceptedAt: new Date().toISOString(),
113
128
  session: {
@@ -121,6 +136,7 @@ export class AcceptedEmailSpool {
121
136
  },
122
137
  });
123
138
  cachedEmail.updateSenderDomain();
139
+ cachedEmail.updateRecipientDomains();
124
140
  await cachedEmail.save();
125
141
  if (context.abortSignal?.aborted) {
126
142
  cachedEmail.markFailed('Message acceptance aborted before SMTP success');
@@ -161,6 +177,9 @@ export class AcceptedEmailSpool {
161
177
  cachedEmail.rawContent = this.setDcRouterCacheIdHeader(rawMessage, cachedEmail.id);
162
178
  cachedEmail.status = 'pending';
163
179
  cachedEmail.nextAttempt = new Date();
180
+ cachedEmail.direction = optionsArg.direction
181
+ ?? (optionsArg.session.authenticated ? 'outbound' : 'inbound');
182
+ cachedEmail.acceptedAt = Date.now();
164
183
  cachedEmail.routeData = JSON.stringify({
165
184
  acceptedAt: new Date().toISOString(),
166
185
  session: {
@@ -174,6 +193,7 @@ export class AcceptedEmailSpool {
174
193
  },
175
194
  });
176
195
  cachedEmail.updateSenderDomain();
196
+ cachedEmail.updateRecipientDomains();
177
197
  await cachedEmail.save();
178
198
 
179
199
  if (optionsArg.processAfterAccept !== false) {
@@ -246,7 +266,7 @@ export class AcceptedEmailSpool {
246
266
 
247
267
  public trackQueueUpdate(
248
268
  item: TSmartMtaQueueItemLike,
249
- status: 'queued' | 'delivered' | 'failed',
269
+ status: 'queued' | 'deferred' | 'delivered' | 'failed',
250
270
  failureMessage: string,
251
271
  ): void {
252
272
  const updatePromise = this.updateAcceptedEmailFromQueueItem(item, status).catch((error) => {
@@ -308,6 +328,9 @@ export class AcceptedEmailSpool {
308
328
  if (this.stopping || this.dcRouterRef.emailServer !== emailServer) {
309
329
  break;
310
330
  }
331
+ if (await this.postponeLiveSmartMtaOwnedEmail(cachedEmail, emailServer)) {
332
+ continue;
333
+ }
311
334
  const session = this.buildCachedEmailSession(cachedEmail);
312
335
  const rawMessage = plugins.buffer.Buffer.from(cachedEmail.rawContent || '', 'utf8');
313
336
  await this.processAcceptedCachedEmail(cachedEmail, rawMessage, session, emailServer);
@@ -346,7 +369,7 @@ export class AcceptedEmailSpool {
346
369
  cachedEmail.markDelivered();
347
370
  } else {
348
371
  const currentCachedEmail = await CachedEmail.findById(cachedEmail.id) || cachedEmail;
349
- if (this.isCachedEmailTerminal(currentCachedEmail)) {
372
+ if (this.isCachedEmailTerminal(currentCachedEmail) || currentCachedEmail.status === 'deferred') {
350
373
  return;
351
374
  }
352
375
  currentCachedEmail.status = 'queued';
@@ -419,7 +442,7 @@ export class AcceptedEmailSpool {
419
442
 
420
443
  private async updateAcceptedEmailFromQueueItem(
421
444
  item: TSmartMtaQueueItemLike,
422
- status: 'queued' | 'delivered' | 'failed',
445
+ status: 'queued' | 'deferred' | 'delivered' | 'failed',
423
446
  ): Promise<void> {
424
447
  const cachedEmailId = this.getCachedEmailIdFromQueueItem(item);
425
448
  if (!cachedEmailId || !this.dcRouterRef.dcRouterDb?.isReady()) {
@@ -433,10 +456,16 @@ export class AcceptedEmailSpool {
433
456
  return;
434
457
  }
435
458
  cachedEmail.attempts = Math.max(cachedEmail.attempts || 0, item.attempts || 0);
459
+ this.updateSmartMtaRouteData(cachedEmail, item, status);
436
460
  if (status === 'delivered') {
437
461
  cachedEmail.markDelivered();
438
462
  } else if (status === 'failed') {
439
463
  cachedEmail.markFailed(item.lastError || 'SmartMTA delivery failed');
464
+ } else if (status === 'deferred') {
465
+ cachedEmail.status = 'deferred';
466
+ cachedEmail.lastError = item.lastError || 'SmartMTA delivery deferred';
467
+ const smartMtaNextAttempt = item.nextAttempt || new Date(Date.now() + ACCEPTED_EMAIL_RETRY_DELAY_MS);
468
+ cachedEmail.nextAttempt = new Date(smartMtaNextAttempt.getTime() + ACCEPTED_EMAIL_QUEUE_LEASE_MS);
440
469
  } else {
441
470
  cachedEmail.status = 'queued';
442
471
  cachedEmail.nextAttempt = new Date(Date.now() + ACCEPTED_EMAIL_QUEUE_LEASE_MS);
@@ -487,11 +516,43 @@ export class AcceptedEmailSpool {
487
516
  return cachedEmail.status === 'delivered' || cachedEmail.status === 'failed';
488
517
  }
489
518
 
490
- private getCachedEmailIdFromQueueItem(item: TSmartMtaQueueItemLike): string | undefined {
519
+ /** The CachedEmail row a live queue item was spooled from (via the cache-id header). */
520
+ public getCachedEmailIdFromQueueItem(item: TSmartMtaQueueItemLike): string | undefined {
491
521
  return this.getHeaderValue(item.processingResult?.headers, DCROUTER_CACHE_ID_HEADER)
492
522
  || this.getHeaderValue(item.processingResult?.email?.headers, DCROUTER_CACHE_ID_HEADER);
493
523
  }
494
524
 
525
+ private async postponeLiveSmartMtaOwnedEmail(
526
+ cachedEmail: CachedEmail,
527
+ emailServer: UnifiedEmailServer,
528
+ ): Promise<boolean> {
529
+ if (cachedEmail.status !== 'deferred') {
530
+ return false;
531
+ }
532
+ const queueItem = this.getLiveQueueItemForCachedEmail(cachedEmail.id, emailServer);
533
+ if (!queueItem) {
534
+ return false;
535
+ }
536
+ const status = queueItem.status === 'deferred' ? 'deferred' : 'queued';
537
+ cachedEmail.attempts = Math.max(cachedEmail.attempts || 0, queueItem.attempts || 0);
538
+ if (queueItem.lastError) {
539
+ cachedEmail.lastError = queueItem.lastError;
540
+ }
541
+ this.updateSmartMtaRouteData(cachedEmail, queueItem, status);
542
+ const smartMtaNextAttempt = queueItem.nextAttempt || new Date(Date.now() + ACCEPTED_EMAIL_RETRY_DELAY_MS);
543
+ cachedEmail.nextAttempt = new Date(smartMtaNextAttempt.getTime() + ACCEPTED_EMAIL_QUEUE_LEASE_MS);
544
+ await cachedEmail.save();
545
+ return true;
546
+ }
547
+
548
+ private getLiveQueueItemForCachedEmail(
549
+ cachedEmailId: string,
550
+ emailServer: UnifiedEmailServer,
551
+ ): TSmartMtaQueueItemLike | undefined {
552
+ const queueItems = (emailServer as TSmartMtaQueueReader).getQueueItems?.() || [];
553
+ return queueItems.find((item) => this.getCachedEmailIdFromQueueItem(item) === cachedEmailId);
554
+ }
555
+
495
556
  private getHeaderValue(headers: Record<string, string> | undefined, headerName: string): string | undefined {
496
557
  if (!headers) {
497
558
  return undefined;
@@ -501,6 +562,21 @@ export class AcceptedEmailSpool {
501
562
  return matchingHeaderName ? headers[matchingHeaderName] : undefined;
502
563
  }
503
564
 
565
+ private updateSmartMtaRouteData(
566
+ cachedEmail: CachedEmail,
567
+ item: TSmartMtaQueueItemLike,
568
+ status: 'queued' | 'deferred' | 'delivered' | 'failed',
569
+ ): void {
570
+ const routeData = this.parseCachedEmailRouteData(cachedEmail);
571
+ routeData.smartMta = {
572
+ status,
573
+ nextAttempt: item.nextAttempt?.toISOString(),
574
+ lastError: item.lastError,
575
+ updatedAt: new Date().toISOString(),
576
+ };
577
+ cachedEmail.routeData = JSON.stringify(routeData);
578
+ }
579
+
504
580
  private removeHeader(headers: Record<string, string>, headerName: string): void {
505
581
  const normalizedHeaderName = headerName.toLowerCase();
506
582
  for (const key of Object.keys(headers)) {
@@ -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,
@@ -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
  }